ClamAV in a container: one centralized, hardened, RAM-frugal clamd
On a server that handles both mail (amavis + sendmail/postfix) and proxying (squid), you quickly end up with several
clamdinstances, 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 containerizedclamd, 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
- The problem: one
clamdper client - The target architecture
- Sharing the host’s signature database (no duplication)
- The hardened container, line by line
- Exposing
clamdwithout endangering it - Giving
clamdaccess to amavis’s scan directory (without root) - Repointing the services — and two non-obvious traps
- Test evidence (EICAR test string only)
- Limits and trade-offs
- Rollback
- 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@amavisdfor amavis (mail filtering),clamd@scanforclamav-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:
| Principle | Implementation |
|---|---|
| One database, one update | Host freshclam; database mounted read-only in the container. |
| Least privilege | clamd non-root, capabilities reduced to zero at runtime, NoNewPrivileges. |
| No network exposure | Loopback 127.0.0.1 and/or a group-restricted unix socket. Never 0.0.0.0. |
| Bounded RAM | systemd 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
chowntrapThe official
clamav/clamavimage runs achownon/var/lib/clamavat boot (to hand the database to itsclamavuser). 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 pointDatabaseDirectoryat it. We let/var/lib/clamavbe an ordinary, writable Podman volume that the entrypoint canchownfreely — 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,squidorclamavwould be less safe: either the group isn’t shared by all consumers, or you over-grant rights (e.g. giving squid theclamavgroup 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 theclamav-clientsgroup. The socket therefore automatically belongs to the right group — no post-startchgrp, 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.dentry 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
amavisto theclamav-clientsgroup and restarting amavisd, you’d expect it to be able to open the socket. It fails with “Permission denied” instead and falls back to theclamscanCLI — 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. Theclamav-clientsgroup 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); # <- requiredVerify 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.serviceresurrects the hostclamdThe systemd unit shipped by the distribution contains:
# /usr/lib/systemd/system/amavisd.service Wants=clamd@amavisd.serviceConsequence: every amavis restart relaunches the host
clamdyou 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.serviceConfirm that only one
clamdremains (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
| Check | Command | Expected | Measured |
|---|---|---|---|
| Loopback-only listener | ss -ltn | grep 3310 | 127.0.0.1:3310 | ✅ |
clamd non-root | …/proc/$PID/status Uid | 100 (clamav) | ✅ |
| No active capability | CapEff | 0000…0000 | ✅ |
| Minimal bounding set | CapBnd | …00cb (5 caps) | ✅ |
| Group socket | stat socket | srw-rw---- clamav-clients | ✅ |
| Non-member refused | nobody connection | Permission denied | ✅ |
| Database up to date | sigtool --info | current version | ✅ 28037 |
| RAM under cap | MemoryCurrent / MemoryPeak | < MemoryMax | ✅ 961 / 1000 MiB |
A single clamd | ps … clamd | container 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 =
clamavon 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).
clamdreads 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:romount.- Signature freshness. Clients see new signatures at worst one
SelfCheckcycle (~10 min) after the host’sfreshclam. TuneSelfCheckto your tolerance. - Daemon dependency. The container is a single point of failure for scan speed — but not for coverage: the
clamscanfallback 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
-
clamdnon-root in the container (User clamav, Uid 100). - No listener on
0.0.0.0— loopback127.0.0.1and unix socket only. - Unix socket
0660+ dedicated groupclamav-clients(not0666); 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:
clamdcannot 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.