DEV Community

Cover image for Building a Centralized ClamAV Scanning Service on AWS with EFS and Terraform
Nowsath for AWS Community Builders

Posted on Originally published at builder.aws.com

Building a Centralized ClamAV Scanning Service on AWS with EFS and Terraform

The Problem

We run a web application on AWS where users upload files – documents, images, audio, 3D assets, the works. Anything a user can upload, an attacker can try to weaponize. So every uploaded file needs to be scanned for malware before the app trusts it.

Running a full antivirus engine inside every application instance is wasteful: the virus database is hundreds of MB, it needs constant updates, and in an auto-scaling fleet you'd be duplicating that everywhere. The cleaner pattern is a centralized scanning service: one dedicated ClamAV host that the whole fleet talks to over the network, with uploaded files sitting on a shared filesystem both the app and the scanner can see.

This post walks through how we built exactly that on AWS – a dedicated ClamAV EC2 instance exposing clamd over TCP, a shared EFS volume mounted on both the app servers and the scanner, wired together with Terraform. I'll also cover a few real gotchas that cost us time, so you can skip them.

Prerequisites

Before you start, you'll want the following in place:

AWS infrastructure (assumed to already exist):

  • A VPC with at least one private subnet per Availability Zone you plan to use. The examples use three AZs for the app tier.
  • An IAM instance profile for the ClamAV EC2 instance. It needs SSM permissions if you want to manage/verify the box via Session Manager, and (if you use the EFS API fallback) read access to describe mount targets.
  • An EC2 key pair (optional if you rely solely on SSM Session Manager).
  • The app tier's security group ID, so you can scope ingress rules SG-to-SG rather than by CIDR.
  • A place to create a DNS record – a Route 53 hosted zone or wherever your internal DNS is managed - for the clamav.* hostname pointing at the scanner's private IP.

Tooling:

  • Terraform ≥ 1.3 and the AWS provider ~> 5.0.
  • The AWS CLI configured with credentials that can create EC2, EFS, security group, ENI, and (optionally) Route 53 resources. If your org enforces MFA, make sure you're using an MFA-authenticated session.

AMI / OS:

  • An Amazon Linux 2023 AMI. Grab the latest via SSM Parameter Store:
aws ssm get-parameter \
--name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel default-x86_64 \
--query "Parameter.Value" --output text
Enter fullscreen mode Exit fullscreen mode

ClamAV, amazon-efs-utils, and cronie all come from the default AL2023 repos, so no extra package sources are needed.

Architecture

Architecture

  • Shared EFS volume mounted at /shared on the app servers and the ClamAV instance. The app writes uploads there; the scanner reads them.
  • Dedicated ClamAV instance running clamd listening on TCP 3310.
  • A DNS record (e.g. clamav.internal.example.net) pointing at the scanner's private IP, so the app connects by name, not a hardcoded IP.
  • Security groups allowing the app tier to reach clamd (3310) and both tiers to reach EFS (NFS 2049).

Why a static ENI? (The gotcha that shaped the design)

The single most important design decision came from a past incident: in our staging environment, the ClamAV instance's private IP was baked into a DNS record. When the instance was later replaced (an EBS encryption operation), it came back with a different private IP – and the DNS record silently pointed at nothing. File scanning broke until someone manually fixed the record.

The fix: give the ClamAV instance a standalone Elastic Network Interface (ENI) with a fixed private IP. An ENI is an independent resource – the instance attaches to it. If the instance is terminated and replaced, the ENI (and its IP) persists, so the DNS record stays valid. No more surprise breakage.

This is a great general lesson: if something points at a private IP, put that IP on an ENI, not on the instance.

The Terraform

Here's the core of it. Assume the VPC, subnets, IAM instance profile, and key pair already exist (they usually do in a real environment), so we reference them as variables.

Security groups

resource "aws_security_group" "clamav" {
  name        = "clamav"
  description = "ClamAV daemon instance"
  vpc_id      = var.vpc_id
}

# clamd 3310 from the app tier
resource "aws_security_group_rule" "clamav_from_app" {
  type                     = "ingress"
  from_port                = 3310
  to_port                  = 3310
  protocol                 = "tcp"
  security_group_id        = aws_security_group.clamav.id
  source_security_group_id = var.app_server_sg_id
}

resource "aws_security_group" "efs" {
  name        = "efs-shared"
  description = "EFS mount targets for the shared volume"
  vpc_id      = var.vpc_id
}

# NFS 2049 from both the app tier and the ClamAV instance
resource "aws_security_group_rule" "efs_from_clamav" {
  type                     = "ingress"
  from_port                = 2049
  to_port                  = 2049
  protocol                 = "tcp"
  security_group_id        = aws_security_group.efs.id
  source_security_group_id = aws_security_group.clamav.id
}
Enter fullscreen mode Exit fullscreen mode

EFS

resource "aws_efs_file_system" "shared" {
  creation_token   = "owl-meta-shared"
  encrypted        = true
  performance_mode = "generalPurpose"
  throughput_mode  = "elastic"
  tags = { Name = "SHARED" }
}

# One mount target per AZ your instances live in
resource "aws_efs_mount_target" "shared" {
  for_each        = toset(var.mount_target_subnets)
  file_system_id  = aws_efs_file_system.shared.id
  subnet_id       = each.value
  security_groups = [aws_security_group.efs.id]
}
Enter fullscreen mode Exit fullscreen mode

Static ENI + instance

resource "aws_network_interface" "clamav" {
  subnet_id       = var.clamav_subnet_id
  private_ips     = [var.clamav_private_ip]
  security_groups = [aws_security_group.clamav.id]
}

resource "aws_instance" "clamav" {
  ami           = var.ami_id            # Amazon Linux 2023
  instance_type = "t3.medium"
  key_name      = var.key_pair_name
  iam_instance_profile = var.iam_instance_profile

  network_interface {
    network_interface_id = aws_network_interface.clamav.id
    device_index         = 0
  }

  user_data = templatefile("${path.module}/user_data.sh.tftpl", {
    efs_id = aws_efs_file_system.shared.id
    region = var.region
  })
  user_data_replace_on_change = true

  metadata_options {
    http_tokens = "required" # IMDSv2
  }

  depends_on = [aws_efs_mount_target.shared]
}
Enter fullscreen mode Exit fullscreen mode

Bootstrapping the instance (and the bugs I hit)

The user-data script installs ClamAV, mounts EFS, configures clamd, and sets up a daily definition refresh. Getting it robust took a couple of iterations.

Gotcha #1: set -e + a boot-time race = no ClamAV at all

My first script started with set -euo pipefail and mounted EFS first. On the very first boot, the EFS mount target wasn't resolvable yet (mount targets take a minute or two to become available). The mount failed, set -e aborted the entire script, and ClamAV never got installed.

Two fixes:

  1. Install ClamAV first, independently of the EFS mount.
  2. Make the mount a retry loop that rides out the boot-time race instead of failing hard.
#!/bin/bash
# NOTE: no `set -e` around the mount — a transient EFS failure must not
# prevent ClamAV from being configured.
set -uo pipefail

# 1) ClamAV first, no dependency on EFS
dnf install -y clamav clamav-update clamd
freshclam || true

# ... configure clamd (see gotcha #2) ...

# 2) EFS mount with retry
dnf install -y amazon-efs-utils
mkdir -p /shared
grep -q "$EFS_ID" /etc/fstab || \
  echo "$EFS_ID:/ /shared efs _netdev,tls 0 0" >> /etc/fstab

for i in $(seq 1 30); do
  mountpoint -q /shared && break
  mount -t efs -o tls "$EFS_ID:/" /shared && break
  sleep 10
done
Enter fullscreen mode Exit fullscreen mode

Gotcha #2: an idempotency guard that matched a comment

To make the clamd config idempotent I guarded the append with:

if ! grep -q "TCPSocket 3310" /etc/clamd.d/scan.conf; then ...
Enter fullscreen mode Exit fullscreen mode

Problem: the stock scan.conf ships with a commented-out #TCPSocket 3310 line. grep -q "TCPSocket 3310" matched the comment, so my real config was never appended – and clamd refused to start because it had nothing to listen on. The fix is to anchor on a non-comment line:

if ! grep -qE "^TCPSocket 3310" /etc/clamd.d/scan.conf; then
cat >> /etc/clamd.d/scan.conf <<'EOF'
LocalSocket /run/clamd.scan/clamd.sock
TCPSocket 3310
MaxFileSize 100M
MaxScanSize 200M
StreamMaxLength 100M
ScanOLE2 yes
ScanPDF yes
ScanArchive yes
EOF
fi
systemctl enable --now clamd@scan.service
Enter fullscreen mode Exit fullscreen mode

Note those Max*/Scan* settings: ClamAV scans by file content, not extension, so you don't need to enumerate file types. But the default size limits will silently skip large office/media files – bump them so big PDFs, videos, and archives are actually scanned.

Gotcha #3: minimal AL2023 has no cron

We wanted a nightly job to clean up old files on the shared volume. On a minimal Amazon Linux 2023 image, /etc/cron.d doesn't even exist – cron isn't installed. So:

dnf install -y cronie
systemctl enable --now crond
cat > /etc/cron.d/shared-cleanup <<'EOF'
30 3 * * * root find /shared -type f -mtime +90 -delete 2>/dev/null
EOF
Enter fullscreen mode Exit fullscreen mode

Daily virus definitions with a systemd timer

tee /etc/systemd/system/freshclam-manual.service >/dev/null <<'EOF'
[Unit]
Description=Update ClamAV virus database
[Service]
Type=oneshot
ExecStart=/usr/bin/freshclam
EOF

tee /etc/systemd/system/freshclam-manual.timer >/dev/null <<'EOF'
[Unit]
Description=Daily freshclam update
[Timer]
OnCalendar=*-*-* 02:37:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl enable --now freshclam-manual.timer
Enter fullscreen mode Exit fullscreen mode

In-transit encryption for EFS

Note the mount options: _netdev,tls. The tls flag routes the NFS traffic through the amazon-efs-utils stunnel proxy, encrypting data in transit (on top of EFS's at-rest encryption). A neat side effect: mount output shows127.0.0.1:/ on /shared because the traffic goes through the local proxy. If you see that and wonder why it's localhost – that's TLS working as intended.

Tip: install python3-botocore too. If DNS resolution of the EFS endpoint is briefly slow at boot, the efs-utils helper falls back to looking up the mount target IP via the EFS API – which needs botocore.

Verifying it actually works

Don't just check that the service is "active" – prove detection works with the EICAR test file (a harmless standard antivirus test string):

# from the ClamAV host
printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' \
  > /shared/eicar.txt
clamdscan --stream /shared/eicar.txt
# => /shared/eicar.txt: Eicar-Test-Signature FOUND

# from an app server, over the DNS name
echo VERSION | nc clamav.internal.example.net 3310
# => ClamAV 1.4.x/...
Enter fullscreen mode Exit fullscreen mode

If you get FOUND on the EICAR file and OK on a clean file, your scanner is genuinely working end to end – network path, shared volume, and detection all confirmed.

Scaling the app tier: bake the mount into the AMI

One last consideration. If your app servers run in an Auto Scaling Group, a newly scaled instance won't have /shared mounted unless its bootstrap handles it. You have two options:

  1. Launch template user-data – add the efs-utils install + fstab entry so every new instance mounts on boot.
  2. Bake it into the AMI – pre-install amazon-efs-utils and add the fstab line to the golden image, so _netdev mounts it automatically at every boot.

We went with option 2 (baking) as the long-term approach – it keeps the launch template clean and makes the mount part of the image contract. Important detail: bake the fstab entry, not a live mount. The fstab line is what persists into the image and triggers the mount on every boot; you don't want to snapshot an active NFS connection.

Rough cost expectations
This setup is cheap to run – the dominant cost is a single small EC2 instance. Here's a back-of-the-envelope monthly estimate for eu-west-1 (Ireland), on-demand pricing. (Prices change and vary by region – always confirm with the AWS Pricing Calculator for your own numbers.)

Cost calculation

Notes and levers:

  • The instance is the cost. A t3.medium runs ~$33/month on-demand. If the scanner isn't heavily loaded, a t3.small roughly halves that. A 1-year Savings Plan / Reserved Instance can cut it a further ~30-40%.
  • EFS scales with what you store. At $0.30/GB-month for Standard, the 90-day cleanup cron isn't just hygiene – it's cost control. Enabling EFS Infrequent Access lifecycle (~$0.016/GB-month) can cut storage cost dramatically if files aren't read often.
  • Cross-AZ data transfer costs money. Keeping the ClamAV instance and the app instances that talk to it in the same AZ (or accepting the small cross-AZ charge) is worth a thought at high volume.
  • Free/negligible: mount targets, the ENI, security groups, and same-AZ in-VPC traffic don't add meaningful cost.

For most teams this lands in the ~$35-40/month range – a small price for centralized, always-updated malware scanning across the fleet.

Takeaways

  • Centralize antivirus as a network service instead of per-instance agents.
  • Put IPs that DNS depends on onto a standalone ENI, so they survive instance replacement.
  • Don't let a transient mount failure abort your whole bootstrap – install independent things first, and retry network mounts.
  • Watch out for idempotency guards that match commented-out defaults.
  • Prove it with EICAR, don't just trust "service active."
  • Bake shared-mount config into your AMI if you autoscale.

Managing all of this as Terraform means the whole thing is reviewable, reproducible, and – crucially – the hard-won fixes to those bootstrap gotchas live in code, so the next instance that launches gets them for free.

Top comments (0)