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"]
Baking 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
});
In TCP mode the file must be accessible from the clamd container. Use a shared Docker volume for the upload temporary directory so both containers can read it.

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 the clamav service in production. clamd should only be reachable on the internal Docker network.
  • Set CLAMD_CONF_MaxFileSize and CLAMD_CONF_StreamMaxLength to match your application's upload size limits. Files larger than these values will return a scan error, not a clean verdict.
  • The upload-tmp shared volume is transient by default. In a multi-replica setup, all replicas of app must 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.