ClamAV in a container: one centralized, hardened, RAM-frugal clamd

· Convergent · 14 min

On a server that handles both mail (amavis + sendmail/postfix) and proxying (squid), you quickly end up with several clamd instances, each loading the very same signature database into memory. Each resident instance keeps the whole database in RAM — about 1 GiB per daemon. This article shows, step by step and with the test evidence, how to replace them with a single containerized clamd, non-root and strongly hardened, that shares the host’s signatures and whose RAM is capped by systemd.

Everything below was deployed and re-verified on a Rocky Linux 10 host, Podman 5.8, ClamAV 1.5. The commands are reproducible. The figures quoted are real measurements, not estimates.

Contents

  1. The problem: one clamd per client
  2. The target architecture
  3. Sharing the host’s signature database (no duplication)
  4. The hardened container, line by line
  5. Exposing clamd without endangering it
  6. Giving clamd access to amavis’s scan directory (without root)
  7. Repointing the services — and two non-obvious traps
  8. Test evidence (EICAR test string only)
  9. Limits and trade-offs
  10. Rollback
  11. Security review checklist

1. The problem: one clamd per client

ClamAV comes in two flavors: the one-shot scanner clamscan (reloads the whole database on every call, slow) and the daemon clamd (keeps the database resident, scans in a few milliseconds). Serious integrations use clamd.

The catch: the ClamAV database now holds more than 3.6 million signatures; once loaded and indexed by clamd, it occupies ~1 GiB of RAM (961 MiB measured on the reference host). Yet distributions encourage one instance per client — typically, on a hardened mail server:

  • clamd@amavisd for amavis (mail filtering),
  • clamd@scan for clamav-milter,
  • and sometimes a third one for the proxy.

That’s two to three copies of the same database in RAM, each updated separately by its own freshclam. On the reference machine, consolidation freed several GiB of RAM and eliminated redundant signature downloads.

The goal, then: a single, shared clamd, isolated in a container, and more secure than the host daemons it replaces.


2. The target architecture

        ┌──────────────────────── HOST ────────────────────────┐
        │                                                       │
        │  freshclam (host) ──► /var/lib/clamav   (RW, 1 source)│
        │                              │                        │
        │                              │ bind-mount RO          │
        │                              ▼                        │
        │   ┌─────────── Podman container (Quadlet) ─────────┐  │
        │   │  clamd  (User clamav, NON-root, CapEff=0)      │  │
        │   │  DatabaseDirectory=/var/lib/clamav-host (RO)   │  │
        │   │  MemoryMax=3G  /  NoNewPrivileges               │  │
        │   │     ├─ TCPSocket 127.0.0.1:3310 ──────────────┐ │  │
        │   │     └─ LocalSocket /tmp/clamd.sock            │ │  │
        │   └───────────────────│──────────────────────────│─┘  │
        │     socket exposed via│ mount /tmp                │    │
        │                       ▼                           │    │
        │   /run/clamd-container/clamd.sock                 │    │
        │     (660, group clamav-clients)                   │    │
        │            │                       other          │    │
        │   ┌────────┴────────┐            containers ◄─────┘    │
        │   ▼                 ▼            (internal network)     │
        │ amavis            squid                                │
        │ (member          (member                               │
        │  clamav-clients)  clamav-clients)                      │
        └───────────────────────────────────────────────────────┘

Four design principles:

PrincipleImplementation
One database, one updateHost freshclam; database mounted read-only in the container.
Least privilegeclamd non-root, capabilities reduced to zero at runtime, NoNewPrivileges.
No network exposureLoopback 127.0.0.1 and/or a group-restricted unix socket. Never 0.0.0.0.
Bounded RAMsystemd cgroup cap (MemoryMax).

3. Sharing the host’s signature database (no duplication)

The golden rule: a single source of truth for signatures. The host’s freshclam writes to /var/lib/clamav. The container runs no freshclam: it reads the host’s database, mounted read-only.

# In the Quadlet (see §4):
Volume=/var/lib/clamav:/var/lib/clamav-host:ro
Environment=CLAMAV_NO_FRESHCLAMD=true

And in clamd.conf:

DatabaseDirectory /var/lib/clamav-host

clamd notices the new signatures on its own at each SelfCheck cycle (~10 min by default) and hot-reloads the database. So the freshness seen by clients is at worst SelfCheck after the host’s freshclam.

⚠️ The startup chown trap

The official clamav/clamav image runs a chown on /var/lib/clamav at boot (to hand the database to its clamav user). That is incompatible with a read-only mount at that location: the container would crash on startup.

The fix is to not mount the host database on /var/lib/clamav, but on a different path (/var/lib/clamav-host) and point DatabaseDirectory at it. We let /var/lib/clamav be an ordinary, writable Podman volume that the entrypoint can chown freely — it just contains no signatures.

A must-not-forget prerequisite: enable freshclam on the host. Without it, the database freezes and the container silently serves stale signatures.

systemctl enable --now clamav-freshclam
sigtool --info /var/lib/clamav/daily.cld | grep -E 'Version|Signatures'
# Version: 28037
# Signatures: 355472

4. The hardened container, line by line

We manage the container with Quadlet: a simple .container file that systemd turns into a service. No sprawling podman run, no startup script — everything is declarative and survives updates.

/etc/containers/systemd/clamd.container:

[Unit]
Description=Centralized ClamAV clamd (hardened container)
After=network-online.target
Wants=network-online.target

[Container]
ContainerName=clamd
# Image PINNED to a digest (reproducible supply chain, no moving tag)
Image=docker.io/clamav/clamav@sha256:60e70e270d5703992f7c4f2c5c998a4709abf94153e80af8c461aa3c02513934
# LOCAL listener only: clamd has no authentication.
PublishPort=127.0.0.1:3310:3310
# Default DB volume (writable): the entrypoint chowns it; holds no signatures.
Volume=clamav-db:/var/lib/clamav:Z
# The HOST signature database, READ-ONLY.
Volume=/var/lib/clamav:/var/lib/clamav-host:ro
# amavis temporary directory (path-based scan), READ-ONLY.
Volume=/var/spool/amavisd:/var/spool/amavisd:ro
# Custom clamd.conf: User clamav (NON-root) + host DatabaseDirectory.
Volume=/etc/clamav-container/clamd.conf:/etc/clamav/clamd.conf:ro
# Host directory mounted on /tmp: the /tmp/clamd.sock socket appears host-side.
Volume=/run/clamd-container:/tmp:Z
Environment=CLAMAV_NO_MILTERD=true
Environment=CLAMAV_NO_FRESHCLAMD=true
# --- Hardening ---
NoNewPrivileges=true
DropCapability=all
AddCapability=chown dac_override fowner setgid setuid

[Service]
Restart=always
TimeoutStartSec=600
# --- Bounded RAM (the whole point of the exercise) ---
MemoryHigh=2G
MemoryMax=3G

[Install]
WantedBy=multi-user.target default.target

And the read-only clamd.conf (/etc/clamav-container/clamd.conf):

LogFile /var/log/clamav/clamd.log
LogTime yes
LocalSocket /tmp/clamd.sock
LocalSocketMode 660
TCPSocket 3310
User clamav
DatabaseDirectory /var/lib/clamav-host
StreamMaxLength 100M
MaxThreads 8

Let’s unpack the security choices.

clamd runs as a non-root user

User clamav in clamd.conf. This is the critical point. A container whose process runs as root weakens isolation: any escape flaw yields host root (with rootful podman). Here the final scanning process runs as clamav.

The image entrypoint does start briefly as root to prepare the database, then drops to clamav. That is why we cannot forbid everything outright.

Capabilities trimmed to the bone — then to zero

DropCapability=all removes every Linux capability, then we grant back only five, the strict minimum the root entrypoint needs to prepare files and switch user:

chown  dac_override  fowner  setgid  setuid

What matters is the state of the final clamd process: it runs as clamav and holds no effective capability. Verification:

CLAMPID=$(podman exec clamd pgrep -x clamd)
podman exec clamd sh -c "grep -E 'Cap(Eff|Bnd)' /proc/$CLAMPID/status"
# CapEff: 0000000000000000   <- no active capability
# CapBnd: 00000000000000cb   <- bounding set = exactly the 5 allowed

0xcb maps bit-for-bit to chown (0), dac_override (1), fowner (3), setgid (6), setuid (7) — no more, no less.

NoNewPrivileges and a pinned image

NoNewPrivileges=true prevents any privilege regain via setuid after launch. The image is pinned to its SHA-256 digest, not to a moving tag like stable or latest: we know exactly which binary runs, and a compromised upstream image cannot silently take its place. (Remember to re-verify and update the digest when bumping versions.)

Bounded RAM

systemctl show clamd.service -p MemoryCurrent -p MemoryPeak --value \
  | awk 'NR==1{printf "current: %.0f MiB\n",$1/1048576} NR==2{printf "peak   : %.0f MiB\n",$1/1048576}'
# current: 961 MiB
# peak   : 1000 MiB   (at database load; caps MemoryHigh=2G, MemoryMax=3G)

MemoryHigh applies gentle pressure (throttling); MemoryMax is the hard cap (OOM-kill beyond it). Keep headroom: here clamd sits at ~961 MiB and peaks at ~1 GiB while loading the database. Don’t set MemoryMax below ~1.5 GiB — as the database grows, too low a cap would get clamd killed at load time. The goal is not to starve clamd, but to stop a runaway (large stream, leak) from eating the host’s entire memory.


5. Exposing clamd without endangering it

clamd has no authentication. Anyone who can open its socket can have it scan any file, or saturate it. Therefore: never a network listener.

ss -ltn | grep 3310
# LISTEN  127.0.0.1:3310   <- loopback only, never 0.0.0.0

Loopback TCP is fine for other containers (over a dedicated internal Podman network). But for host services we can do better. Because 127.0.0.1:3310 is reachable by any local process, including a compromised application account unrelated to the antivirus.

The group-restricted unix socket

A unix socket in mode 0660, owned by a dedicated group, can only be opened by members of that group. This is standard, auditable POSIX access control, far finer-grained than a loopback port open to everyone.

We create a dedicated group clamav-clients and add only the authorized services to it:

groupadd -r -g 961 clamav-clients
gpasswd -a amavis clamav-clients
gpasswd -a squid  clamav-clients

Why a dedicated group rather than an existing one? Reusing amavis, squid or clamav would be less safe: either the group isn’t shared by all consumers, or you over-grant rights (e.g. giving squid the clamav group also opens signature files to it). A group whose sole reason to exist is “talk to the clamd socket” is the exact expression of least privilege.

Getting the socket out of the container, cleanly

The image entrypoint watches a specific path: /tmp/clamd.sock. So we don’t move it; we mount a host directory onto the container’s /tmp. The socket clamd creates then appears host-side in that directory.

The directory is created by tmpfiles.d (so it’s recreated at boot — robust) with two subtleties:

/etc/tmpfiles.d/clamd-container.conf:

d /run/clamd-container 2750 100 clamav-clients -
  • Owner uid 100 = the container’s clamav. With rootful podman and no UID remapping, the container’s uid 100 is the host’s uid 100: it is the one that writes the socket.
  • setgid bit (2…): any file created in this directory inherits the clamav-clients group. The socket therefore automatically belongs to the right group — no post-start chgrp, no creation race.

Combined with LocalSocketMode 660 in clamd.conf, we get exactly:

stat -c '%A  %G' /run/clamd-container/clamd.sock
# srw-rw----  clamav-clients

Mode 660, group clamav-clients. A non-member is refused by the kernel:

# As a member (amavis): the socket responds
runuser -u amavis -- \
  python3 -c 'import socket;socket.socket(socket.AF_UNIX).connect("/run/clamd-container/clamd.sock")'
# (ok, no error)

# As a non-member (nobody): refused
runuser -u nobody -- \
  python3 -c 'import socket;socket.socket(socket.AF_UNIX).connect("/run/clamd-container/clamd.sock")'
# PermissionError: [Errno 13] Permission denied

6. Giving clamd access to amavis’s scan directory (without root)

amavis scans by path: it drops the message in its temporary directory and sends clamd a CONTSCAN /path/to/file command. For this to work, clamd must be able to read those files — which are owned amavis:amavis, mode 0640.

The tempting shortcuts would be to run clamd as root, or to add it to the amavis group. Both grant too much. The right answer is a targeted POSIX ACL: grant uid 100 (the container’s clamav) read-only access to amavis’s temporary directory, with a default ACL so files created later inherit it.

TEMPBASE=/var/spool/amavisd
setfacl -R    -m u:100:rX "$TEMPBASE"   # existing
setfacl -R -d -m u:100:rX "$TEMPBASE"   # default (new files)

getfacl -p "$TEMPBASE" | grep -E 'user:100|default:user:100'
# user:100:r-x
# default:user:100:r-x

And the mount on the container side is read-only (:ro): clamd reads, modifies nothing.

Caveat worth knowing: if the temporary directory is recreated from scratch (reinstall, purge), the ACL must be reapplied. For robustness, you can materialize it in a tmpfiles.d entry or a deployment hook.


7. Repointing the services — and two non-obvious traps

amavis

In amavisd.conf, replace the scanner target with the group socket:

['ClamAV-clamd',
  \&ask_daemon, ["CONTSCAN {}\n", "/run/clamd-container/clamd.sock"],
  qr/\bOK$/m, qr/\bFOUND$/m,
  qr/^.*?: (?!Infected Archive)(.*) FOUND$/m ],

⚠️ Trap #1: amavisd discards its supplementary groups

After adding amavis to the clamav-clients group and restarting amavisd, you’d expect it to be able to open the socket. It fails with “Permission denied” instead and falls back to the clamscan CLI — a scan that takes ~25 seconds instead of a few dozen milliseconds.

The cause: when dropping its root privileges, amavisd resets its supplementary groups and keeps only $daemon_group. The clamav-clients group is therefore lost, despite the membership declared in /etc/group.

The fix is to explicitly declare the list of groups amavisd applies:

$daemon_group  = 'amavis';
@daemon_groups = qw(amavis clamav-clients);   # <- required

Verify on the running daemon:

for p in $(pgrep -f amavisd); do grep ^Groups: /proc/$p/status; done | sort -u
# Groups:  961 984 984     <- 961 = clamav-clients present

The host daemons… and the Wants= trap

We disable the now-useless host clamd instances. But disable is not enough:

⚠️ Trap #2: amavisd.service resurrects the host clamd

The systemd unit shipped by the distribution contains:

# /usr/lib/systemd/system/amavisd.service
Wants=clamd@amavisd.service

Consequence: every amavis restart relaunches the host clamd you thought was off — reloading 1.3 GiB of database into RAM. The entire benefit of consolidation evaporates, silently.

The reliable countermeasure is to mask (not just disable) the host instances. A Wants= toward a masked unit fails without blocking amavis’s startup:

systemctl mask clamd@amavisd.service clamd@scan.service
systemctl stop  clamd@amavisd.service clamd@scan.service

Confirm that only one clamd remains (the container’s):

ps -eo user,args | grep '[c]lamd --foreground'
# 100  clamd --foreground     <- uid 100 = the container's clamav, and only it

The clamscan CLI stays configured as a backup scanner: if the container is unavailable, amavis still blocks infections (more slowly), using the host’s up-to-date database. Security does not depend on the container’s availability.


8. Test evidence (EICAR test string only)

Never test with real malware. The EICAR test string is a harmless file, recognized by every antivirus precisely for this kind of validation.

End-to-end functional test

Inject an email containing EICAR into amavis (port 10024) and read the verdict:

amavis[…] Blocked INFECTED (Eicar-Signature) {Quarantined},
          <t@example.com> -> <d@example.com>, … 136 ms

136 ms: the signature of a scan by the daemon over the socket. If you saw ~25,000 ms, that would be the clamscan fallback — the sign the socket isn’t reachable (cf. trap #1).

Direct path scan (CONTSCAN)

# From a group member, on an EICAR dropped in amavis's temp dir:
member: /var/spool/amavisd/tmp/…/eicar.txt: Eicar-Test-Signature FOUND | 21.5 ms

Checks at a glance

CheckCommandExpectedMeasured
Loopback-only listenerss -ltn | grep 3310127.0.0.1:3310
clamd non-root…/proc/$PID/status Uid100 (clamav)
No active capabilityCapEff0000…0000
Minimal bounding setCapBnd…00cb (5 caps)
Group socketstat socketsrw-rw---- clamav-clients
Non-member refusednobody connectionPermission denied
Database up to datesigtool --infocurrent version✅ 28037
RAM under capMemoryCurrent / MemoryPeak< MemoryMax✅ 961 / 1000 MiB
A single clamdps … clamdcontainer only

9. Limits and trade-offs

Technical honesty is part of taking security seriously. This setup has trade-offs you should own:

  • Rootful Podman. The host/container UID equality (uid 100 = clamav on both sides) that makes the ACL and socket so simple relies on rootful mode. In rootless mode, UIDs are remapped (subuid): the ACL would target the remapped UID, and the socket would need a different exit mechanism. It’s doable but more complex; we set it aside here for clarity.
  • SELinux in permissive mode on the demo host. The mounts carry the right context (:Z), but we recommend enforcing in production. To validate: clamd’s access policy to amavis’s temp under enforcing.
  • ACL persistence. If amavis’s temp directory is recreated from scratch, the ACL must be reapplied (cf. §6).
  • clamd reads mail in transit. By design, the engine accesses amavis’s temporary files (read-only). That’s inherent to path-based scanning; we bound it with the ACL and the :ro mount.
  • Signature freshness. Clients see new signatures at worst one SelfCheck cycle (~10 min) after the host’s freshclam. Tune SelfCheck to your tolerance.
  • Daemon dependency. The container is a single point of failure for scan speed — but not for coverage: the clamscan fallback takes over (more slowly) with the host’s database.

10. Rollback

Reverting to the previous state is immediate:

# 1) Re-enable the host clamd instances
systemctl unmask clamd@amavisd.service clamd@scan.service
systemctl enable --now clamd@amavisd.service clamd@scan.service

# 2) Point amavis back to the old target (kept as a comment)
#    then: systemctl restart amavisd

# 3) Stop the container
systemctl stop clamd.service

Since the original configuration files were backed up (*.bak) and the old entries kept as comments, the operation is lossless.


11. Security review checklist

  • clamd non-root in the container (User clamav, Uid 100).
  • No listener on 0.0.0.0 — loopback 127.0.0.1 and unix socket only.
  • Unix socket 0660 + dedicated group clamav-clients (not 0666); non-member refused by the kernel.
  • Image pinned to a digest, no moving tag.
  • Capabilities at the strict minimum (5 in the bounding set, CapEff=0); NoNewPrivileges.
  • Signature database mounted read-only: clamd cannot alter signatures.
  • No secret in this article or in the published configs (real hostnames/IPs anonymized).
  • Updates covered: single source (host freshclam), freshness ≤ SelfCheck.
  • Limits/trade-offs stated (rootful, SELinux permissive, ACL persistence, reading the mail temp).
  • Rollback documented.
  • Reproducible, copy-pasteable commands.

Conclusion

By replacing several clamd instances with a single containerized engine — non-root, with no effective capability, exposed over a group-restricted socket and fed by the host’s database — you win on both fronts: several gigabytes of RAM recovered and a reduced attack surface compared to the original host daemons. The two least obvious traps — amavisd discarding its supplementary groups, and the systemd unit resurrecting the host clamd — are exactly the kind of details that separate a setup that “seems to work” from one that is verified.

All the tested configuration files (Quadlet, clamd.conf, amavisd.conf excerpt, tmpfiles.d, host setup script) are provided ready to copy.


About Convergent

At Convergent, we design and harden Linux infrastructure where security is measurable, not decorative. If you want to audit your antivirus chain, consolidate resources, or harden your containers, let’s talk.