Skip to content

Docker

The published image (ghcr.io/sylvinus/exav) is a drop-in for the ClamAV Docker image on the wire: same clamd port 3310, same clamd protocol. It runs the clamd-compatible daemon by default, so a host clamdscan (or any clamd client) pointed at the container’s TCP 3310 just works.

It’s distroless and rootless: a static-musl binary on distroless/static (no shell, no package manager) running as the nonroot user (uid 65532). Bind-mounted database volumes must be writable by that uid.

Terminal window
# Start the daemon; persist signatures in a named volume. Name a source, or
# fill the volume yourself with `cvd`/`freshclam` first: signatures are not
# bundled, and rather than serve without them exav waits for them to appear
# (EXAV_STARTUP_WAIT_SECS, default 1800). Nothing answers on 3310 until they do.
docker run -d -p 3310:3310 -v exav-db:/var/lib/exav \
-e EXAV_SIG_SOURCES=https://your-mirror/ ghcr.io/sylvinus/exav
clamdscan --stream file.bin # once the load is logged
# One-shot scan. Paths win over the image's `EXAV_LISTEN`, so there is nothing
# to unset — but the scan loads its own database, so it needs the signature
# volume as well as the tree to scan.
docker run --rm -v exav-db:/var/lib/exav -v "$PWD:/scan" ghcr.io/sylvinus/exav /scan

Naming paths is what makes that run a scanner rather than a daemon: EXAV_LISTEN is a default the image sets, and a default gives way to what the command line asks for. A --listen typed on the command line alongside paths is a different thing — two jobs in one command — and is refused rather than guessed at.

The image’s default command is --auto-update, with EXAV_LISTEN set to clamd://0.0.0.0:3310: serve the clamd protocol, and keep the signature volume current — bootstrap it, wait for a sidecar to fill it if that is where signatures come from, refresh the configured sources on a schedule, and hot-reload on every change. Everything else comes from the environment, and any flag on the command line overrides the matching variable, never the other way round:

Terminal window
# EXAV_WORKERS is overridden; every other variable still applies.
docker run -d -p 3310:3310 -e EXAV_WORKERS=8 -v exav-db:/var/lib/exav \
ghcr.io/sylvinus/exav --auto-update --workers 2

The image carries a HEALTHCHECK that runs exav --ping: one exchange in whatever protocol the listener speaks — PING on clamd, OPTIONS on ICAP — so it goes red on a daemon that accepts connections and then answers nothing, which a bare TCP probe would call healthy.

It reads the same EXAV_LISTEN the daemon did, so moving the port or serving ICAP instead needs no change to the check. Run it yourself against any daemon:

Terminal window
docker exec exav /exav --ping # what this container serves
exav --ping --connect clamd://scanner.internal:3310 # from anywhere

A container is unhealthy while it waits for signatures — it is not serving then, and reporting otherwise would send traffic to something that cannot answer. If signatures arrive from a slow sidecar, raise the start period (--health-start-period) so the orchestrator does not kill it mid-wait.

The check answers for the daemon this image defaults to. Give the container a different job — building a database, a --connect client, a one-shot scan — and it reports unhealthy the whole time while working perfectly, because nothing is listening and the check cannot tell that from a daemon that died. Those runs are short and exit on their own, so it is cosmetic under docker run; add --no-healthcheck if something would act on it:

Terminal window
docker run --rm --no-healthcheck -e EXAV_LISTEN= \
-v "$PWD:/scan" ghcr.io/sylvinus/exav /scan

The signatures directory is /var/lib/exav (exav’s own). To reuse an existing ClamAV volume, mount it there or set EXAV_SIG_DIR=/var/lib/clamav.

Mount a /var/lib/exav you keep current yourself with cvd / freshclam. No updater in the container.

Configure the container with exav’s own EXAV_* variables — the four that matter for most deployments:

Variable Default Meaning
EXAV_SIG_DIR /var/lib/exav Signatures directory (loaded recursively).
EXAV_SIG_SOURCES (unset) Where to fetch signatures from: exact URLs, mirror bases (trailing /), or a file of either. Comma-separated for more than one.
EXAV_DB_URL (unset) URL of a prebuilt .exavdb to pull and serve; hot-reloads, and replaces the signature-file sources rather than merging with them.
EXAV_LISTEN clamd://0.0.0.0:3310 What the container serves, and where. Comma-separated for more than one.

Every flag has a variable spelled the same way, so Configuration documents the rest — the startup wait, update interval, EXAV_ALLOW_NO_DB, EXAV_ALLOW_SHUTDOWN, and where a pulled database is stored.

The same image serves ICAP (RFC 3507) on port 1344 when EXAV_LISTEN names an icap:// address, as a drop-in for a c-icap container running the virus_scan service against ClamAV. Adding one adds the listener, so one container replaces the c-icap + clamav pair rather than standing in for half of it:

Terminal window
docker run -d -p 3310:3310 -p 1344:1344 \
-e EXAV_LISTEN=clamd://0.0.0.0:3310,icap://0.0.0.0:1344 \
-v exav-db:/var/lib/exav ghcr.io/sylvinus/exav

Both protocols are then served by one process, over one loaded database — which is the saving, since a full signature set costs seconds and gigabytes to load and a second container mostly exists to avoid paying that twice.

For ICAP and nothing else, name the listener instead of taking the default command:

Terminal window
docker run -d -p 1344:1344 -v exav-db:/var/lib/exav \
ghcr.io/sylvinus/exav --listen icap://0.0.0.0:1344 --auto-update

The port is bound only when asked for. See the ICAP guide for the verdict mapping and the full settings table.

When swapping over from the ClamAV image, translate that image’s CLAMAV_*/FRESHCLAM_* settings to their EXAV_* equivalents — the mapping is in Configuration.

When you bind-mount a host directory (instead of a named volume), make it writable by uid 65532 so the daemon can write signatures and temp files:

Terminal window
mkdir -p ./exav-db && sudo chown 65532:65532 ./exav-db
docker run -d -p 3310:3310 -v "$PWD/exav-db:/var/lib/exav" ghcr.io/sylvinus/exav

An object larger than --spill-threshold-bytes (16M) is written to disk while it is scanned, so a container with --read-only and nowhere to write fails every scan above that size — small objects keep working, which is what makes it look intermittent. Give it a temp filesystem:

Terminal window
docker run -d -p 3310:3310 --read-only --tmpfs /tmp \
-v exav-db:/var/lib/exav ghcr.io/sylvinus/exav

Without one the daemon logs connection error: Permission denied and drops the connection, and the client — clamdscan or exav’s own — reports an error and exits 2. It is never reported as clean, but it is not a verdict either: the scan did not happen. --spill-dir names somewhere other than TMPDIR if that suits the mount layout better.

Separate the updater from the scanner: an updater container refreshes a shared volume and the scanner hot-reloads it — the setup in docker-compose.yml. “Updater” is --auto-update with nothing to listen on, so what each container does is on the line that starts it and neither can contradict itself:

services:
exav: # scanner
image: ghcr.io/sylvinus/exav:latest
ports: ["3310:3310"]
volumes: [exav-db:/var/lib/exav]
updater: # writes the shared volume, doesn't scan
image: ghcr.io/sylvinus/exav:latest
command: ["--auto-update", "--update-interval-secs", "43200"]
environment:
EXAV_LISTEN: "" # the image's default listener, switched off
EXAV_SIG_SOURCES: "${EXAV_SIG_SOURCES}"
volumes: [exav-db:/var/lib/exav]
volumes:
exav-db:

The scanner notices the updated files on the shared mount (an mtime watch, like clamd’s SelfCheck) and re-forks its worker pool with the new signatures — no restart, no cross-container signalling.