Security AWS

Clair 4.9.0 on AWS User Guide

| Product: Clair 4.9.0 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the Clair 4.9.0 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg OWASP ZAP and vulnerability scanners listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

Clair is the open source container image vulnerability scanner from the Quay project. You hand it an image manifest; its indexer downloads the image layers and works out which operating system packages and language dependencies are inside, and its matcher compares that inventory against vulnerability data that its updaters keep current from the distributions and advisory databases (Ubuntu, Debian, Alpine, Red Hat, SUSE, Oracle, Photon, Amazon Linux, the OSV database for language ecosystems, and more). The result is a vulnerability report per image. Registries, CI pipelines and admission controllers call it over an HTTP API; the clairctl client on the image wraps that for you.

Upstream Clair listens on every interface with no authentication and no TLS, and it downloads image layers from whatever URLs a client names. The cloudimg image makes it a service you can safely put in a VPC:

  • TLS and a password from the first boot. nginx serves the API on port 443 (https://<host>/) with a certificate generated on your instance's first boot for its DNS name and IP addresses. Every API request needs HTTP basic authentication with the user clair and a password generated on that first boot (written to /root/clair-credentials.txt, root only); only the /healthz health check is open. Clair itself listens on 127.0.0.1 only, and nothing listens on port 80.
  • An egress firewall for the scanner. A client chooses the layer URLs Clair fetches, so an unprotected Clair can be pointed at addresses inside your network. On this image every connection the clair user opens to the metadata service, link local, carrier grade NAT, private (RFC 1918), benchmarking, multicast and reserved addresses, and to your own VPC's address ranges (read from EC2 metadata on every boot), is refused by the kernel, with a second, independent block in the service's own cgroup. IPv6 is closed by default. The rules load before Clair on every boot, and Clair refuses to start without them.
  • Its own database, on its own disk. PostgreSQL 16 listens on 127.0.0.1 only, with a database password generated on first boot; the cluster lives on a separate 40 GiB gp3 data volume mounted at /var/lib/postgresql, which you can resize on its own.
  • Fresh vulnerability data. No vulnerability data is baked into the image. Clair's updaters download the full database when the instance first starts and refresh it every 6 hours, so the data is never older than your launch.
  • Locked down. Clair runs as an unprivileged user in a sandboxed service; Clair, nginx and the first boot service cannot reach the instance metadata service, and the AMI requires IMDSv2. Requests for host names the instance does not serve are dropped.
  • Built from source. Clair 4.9.0 and clairctl are compiled from the upstream release (pinned by SHA-256) with the pinned Go 1.27.3 toolchain, with dependencies raised to versions that fix the published advisories in the upstream set, on fully patched Ubuntu 24.04 LTS with unattended security upgrades switched on.

What is on the image: Clair 4.9.0 and clairctl (/opt/clair, licence and build provenance in /usr/share/doc/clair), PostgreSQL 16, nginx, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.xlarge (4 vCPU, 16 GiB) for the fastest first database download; the listing's default m5.large (2 vCPU, 8 GiB) also works, with a slower first run

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet your registries and CI runners can reach. The instance needs outbound HTTPS to the internet: to the vulnerability feeds (Ubuntu, Debian, Alpine, Red Hat, SUSE, OSV and the others) and to the registries that hold the images you scan.
  • A security group that allows TCP 22 (SSH) from your own networks only, and TCP 443 from the machines that call the API (CI runners, your registry, developers). Nothing else needs to be open.
  • An instance with at least 8 GiB of memory. The first download of the vulnerability database is the heaviest moment in Clair's life; m5.xlarge (16 GiB) seeds it faster and leaves room for scanning large images, and m5.large (8 GiB) works too (Clair's memory is held to 60% of RAM, see Step 10).

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Clair 4.9.0 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.xlarge, your key pair, and a security group as above. The AMI attaches the 40 GiB data volume for /var/lib/postgresql automatically; increase its size in the launch wizard's storage section if you will keep index reports for many thousands of images.

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.xlarge --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=clair}]'

Step 2: Connect to your instance

Version SSH user
Clair 4.9.0 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

First boot generates the certificate, the database password and the API password; the egress firewall, PostgreSQL, Clair and nginx then start:

systemctl is-active clair nginx postgresql@16-main cloudimg-clair-egress cloudimg-clair-vpc-cidrs
sudo test -f /var/lib/cloudimg/clair-firstboot.done && echo FIRSTBOOT_DONE
active
active
active
active
active
FIRSTBOOT_DONE

Only SSH and nginx listen on the network. Clair's API (6060) and introspection port (8089), and PostgreSQL (5432), are on loopback only:

sudo ss -lntp | awk 'NR>1 {print $4, $6}' | sed -E 's/pid=[0-9]+,fd=[0-9]+/.../g' | sort
0.0.0.0:22 users:(("sshd",...),("systemd",...))
0.0.0.0:443 users:(("nginx",...),("nginx",...),("nginx",...),("nginx",...),("nginx",...))
127.0.0.1:5432 users:(("postgres",...))
127.0.0.1:6060 users:(("clair",...))
127.0.0.1:8089 users:(("clair",...))
127.0.0.53%lo:53 users:(("systemd-resolve",...))
127.0.0.54:53 users:(("systemd-resolve",...))
[::]:22 users:(("sshd",...),("systemd",...))
[::]:443 users:(("nginx",...),("nginx",...),("nginx",...),("nginx",...),("nginx",...))

The database is on its own volume:

df -h /var/lib/postgresql | awk 'NR==2 {print $2, $6}'
40G /var/lib/postgresql
15G /

Step 4: Wait for the vulnerability database

The image ships no vulnerability data, so nothing in it is stale on the day you launch. When Clair first starts, its updaters download the full database from every source: the Ubuntu, Debian, Alpine, SUSE, Oracle Linux, Photon and Amazon Linux security feeds, Red Hat's VEX documents, the OSV database for language packages (Go, npm, PyPI, Maven, RubyGems, crates.io, NuGet, Packagist and others) and the CVSS enrichment data. On our test launch the first run took 114 minutes on a 4 vCPU, 16 GiB instance and stored about 10.1 million vulnerability records (a 15 GB database); allow 1 to 3 hours. The first run is the heaviest work Clair does: it needs about 7 GiB of memory and, while it runs, several GiB of temporary download space, which is why we recommend 16 GiB of memory. On a 2 vCPU, 8 GiB instance (m5.large) the first run also completes, in about 2.5 hours on our test. Red Hat's feed is usually the last to finish: its updater fetches every advisory changed since Red Hat's weekly archive one by one, so it takes longest in a week when Red Hat has republished many advisories. After the first run, Clair refreshes every source every 6 hours and only downloads what changed.

Clair answers API calls while it seeds, but a report is only complete once the first run has finished. To see where it is:

sudo clair-scan --status
                   updater                    | ok |      last_success      
----------------------------------------------+----+------------------------
 alpine-community-edge-updater                | t  | 2026-11-08 09:41:54+00
 alpine-community-v3.10-updater               | t  | 2026-11-08 09:41:52+00
 alpine-community-v3.11-updater               | t  | 2026-11-08 09:41:52+00
 alpine-community-v3.12-updater               | t  | 2026-11-08 09:41:54+00
 alpine-community-v3.13-updater               | t  | 2026-11-08 09:41:54+00
 alpine-community-v3.14-updater               | t  | 2026-11-08 09:41:52+00
 ...
 oracle-2007-updater                          | f  | 
 oracle-2008-updater                          | f  | 
 ...
 ubuntu/updater/xenial                        | t  | 2026-11-08 09:41:54+00
(125 rows)

ok is t for every source that has completed. Oracle Linux: on our test launch, linux.oracle.com answered every request from Clair's OVAL fetcher with HTTP 403 (it refuses that fetcher's user agent; the same files download normally with other clients), so the twenty oracle-YYYY-updater rows stayed at f and Clair reports no findings for Oracle Linux images while that lasts. Every other source completed. Clair retries every 6 hours, so the rows turn to t without any action from you once Oracle accepts the requests again; until then, scan Oracle Linux images with another tool. To check:

sudo clair-scan --status | awk '/oracle-/ {n++; if ($3 == "t") ok++} END {print ok+0 " of " n+0 " Oracle Linux sources ok"}'
sudo journalctl -u clair -o cat -g 'oracle-[0-9]+-updater: ovalutil' | tail -1 | grep -oE 'ovalutil: fetcher got unexpected HTTP response: [0-9]+ \([^)]*\)' | sort | uniq -c
0 of 20 Oracle Linux sources ok
     20 ovalutil: fetcher got unexpected HTTP response: 403 (403 Forbidden)

The first run is complete when the journal says so (Clair adds one more line each time it restarts):

sudo journalctl -u clair -o cat -g 'starting background updates'
2026/11/08 09:30:44 INFO starting background updates interval=6h0m0s

Step 5: Get your API URL and password

The credentials file is readable by root only. This prints it with the password hidden; sudo cat /root/clair-credentials.txt shows the password itself:

sudo sed -E 's/^(CLAIR_API_PASSWORD=).*/\1<hidden>/' /root/clair-credentials.txt
# Clair 4.9.0 - generated on first boot by clair-firstboot.service
# This password is unique to this instance; none was baked into the image. Keep this file secret.
# Every API request needs it (HTTP basic auth over HTTPS); only /healthz is open.

CLAIR_API_URL=https://ec2-52-87-223-107.compute-1.amazonaws.com/
CLAIR_HOST=ec2-52-87-223-107.compute-1.amazonaws.com
CLAIR_API_USER=clair
CLAIR_API_PASSWORD=<hidden>
CLAIR_CA_CERT=/etc/nginx/tls/clair.crt
CLAIR_CERT_SHA256=8A:41:95:2E:4E:4A:62:EC:CC:A5:D8:76:9F:88:6C:4A:2D:C0:BF:56:35:C7:05:3F:25:6A:20:BE:3D:0A:70:C1
#
# The vulnerability database downloads on first boot (allow 1 to 3 hours; see the guide): sudo clair-scan --status
# Scan an image on this instance:  sudo clair-scan docker.io/library/alpine:3.19
# From a client: curl --cacert <a copy of clair.crt> -u clair:<CLAIR_API_PASSWORD> https://<CLAIR_HOST>/indexer/api/v1/index_state
# New API password:              sudo clair-rotate-password
# New database password:         sudo clair-rotate-db-password
# Change the name it serves as:  sudo clair-set-hostname <your-dns-name>
# User guide: https://www.cloudimg.co.uk/guides/clair-4-9-0-on-ubuntu-24-04-aws/

Step 6: Scan an image on the instance

clair-scan runs the clairctl client against this instance's Clair: it asks Clair to index the image (Clair pulls the layers straight from the registry, inside the egress firewall), then prints the vulnerability report. It needs root because it runs clairctl as the unprivileged clair user:

sudo clair-scan docker.io/library/alpine:3.19.0
alpine:3.19.0 found busybox       1.36.1-r15           CVE-2025-46394 (fixed: 1.36.1-r21)
alpine:3.19.0 found busybox       1.36.1-r15           CVE-2024-58251 (fixed: 1.36.1-r21)
alpine:3.19.0 found busybox       1.36.1-r15           CVE-2023-42363 (fixed: 1.36.1-r17)
alpine:3.19.0 found busybox       1.36.1-r15           CVE-2023-42365 (fixed: 1.36.1-r19)
alpine:3.19.0 found busybox       1.36.1-r15           CVE-2023-42364 (fixed: 1.36.1-r19)
alpine:3.19.0 found busybox       1.36.1-r15           CVE-2023-42366 (fixed: 1.36.1-r16)
alpine:3.19.0 found busybox-binsh 1.36.1-r15           CVE-2025-46394 (fixed: 1.36.1-r21)
alpine:3.19.0 found busybox-binsh 1.36.1-r15           CVE-2024-58251 (fixed: 1.36.1-r21)
alpine:3.19.0 found busybox-binsh 1.36.1-r15           CVE-2023-42363 (fixed: 1.36.1-r17)
alpine:3.19.0 found busybox-binsh 1.36.1-r15           CVE-2023-42365 (fixed: 1.36.1-r19)
alpine:3.19.0 found busybox-binsh 1.36.1-r15           CVE-2023-42364 (fixed: 1.36.1-r19)
alpine:3.19.0 found busybox-binsh 1.36.1-r15           CVE-2023-42366 (fixed: 1.36.1-r16)

--json prints Clair's full vulnerability report instead, which is what a pipeline or policy engine wants. For example, counting the findings per package:

sudo clair-scan --json docker.io/library/alpine:3.19.0 | jq -r '[.vulnerabilities[] | .package.name] | group_by(.) | map("\(.[0]) \(length)") | .[]'
busybox 6
musl 3
openssl 12

You can name several images in one call. clair-scan pulls anonymously (it keeps no registry credentials on the instance), so it suits public images. For images in private registries, run clairctl on a machine that is logged in to the registry (Step 7): clairctl reads your Docker credentials there and hands Clair the layer URLs with the registry's short lived access headers, so the registry password never reaches the Clair instance.

Step 7: Use the API from your CI and registries

Copy the certificate to the machines that call Clair (or use your own certificate, Step 9), and keep the password in your CI system's secret store. Every path except /healthz needs HTTP basic authentication with the user clair. The indexer state is a quick check that your client, the certificate and the password all work:

# from a host inside your network that has a copy of clair.crt
curl --cacert clair.crt -u clair:<CLAIR_API_PASSWORD> https://<CLAIR_HOST>/indexer/api/v1/index_state
{"state":"150b86c03ad8d5fd1b8fdff4a351aed8"}

To scan from a client, install the same clairctl release (Clair v4.9.0 release assets) and point it at the instance: clairctl sends the image manifest to POST /indexer/api/v1/index_report and then reads GET /matcher/api/v1/vulnerability_report/<manifest-digest>. Tools that integrate Clair v4 (Quay, and CI plugins that speak the Clair v4 API) need the same three things: the URL https://<CLAIR_HOST>/, the user clair with the password, and the certificate. The OpenAPI description of every endpoint is served at https://<CLAIR_HOST>/openapi/v1.

Step 8: Check the security posture

Everything but the health check needs the password, a request for a host name the instance does not serve is dropped without an answer, and nothing answers on port 80:

sudo cp /etc/nginx/tls/clair.crt /tmp/clair.crt; C=/tmp/clair.crt
curl -s --cacert $C -o /dev/null -w 'index_state, no password -> HTTP %{http_code}\n' https://localhost/indexer/api/v1/index_state
curl -s --cacert $C -K <(printf 'user = "clair:not-the-password"\n') -o /dev/null -w 'wrong password            -> HTTP %{http_code}\n' https://localhost/indexer/api/v1/index_state
curl -s --cacert $C -o /dev/null -w 'health check              -> HTTP %{http_code}\n' https://localhost/healthz
curl -s --cacert $C -H 'Host: evil.example' -o /dev/null -w 'unknown host              -> %{http_code}\n' https://localhost/healthz || true
curl -s -o /dev/null -w 'plain http :80            -> %{http_code}\n' --connect-timeout 3 http://127.0.0.1/ || true
index_state, no password -> HTTP 401
wrong password            -> HTTP 401
health check              -> HTTP 200
unknown host              -> 000
plain http :80            -> 000

000 means no answer: nginx closes the connection for a host name it does not serve, and nothing listens on port 80.

The certificate is a leaf for the instance's names:

openssl x509 -noout -ext subjectAltName,basicConstraints,keyUsage,extendedKeyUsage -in /etc/nginx/tls/clair.crt
X509v3 Subject Alternative Name: 
    DNS:ec2-52-87-223-107.compute-1.amazonaws.com, IP Address:52.87.223.107, IP Address:172.31.82.5, IP Address:127.0.0.1, DNS:localhost
X509v3 Basic Constraints: critical
    CA:FALSE
X509v3 Key Usage: critical
    Digital Signature, Key Encipherment
X509v3 Extended Key Usage: 
    TLS Web Server Authentication

The egress firewall. Clair downloads image layers from the URLs in the manifests clients send it, so the image restricts where the clair user may connect. Every boot, cloudimg-clair-vpc-cidrs.service reads this instance's own VPC IPv4 ranges (and any VPC or subnet IPv6 ranges) from the EC2 metadata service as root, and cloudimg-clair-egress.service loads the rules before Clair starts. The ranges it found:

sudo cat /run/cloudimg-clair/vpc-cidrs
# written by clair-vpc-cidrs from EC2 metadata at 2026-11-08T09:31:48Z
source imds
4 172.31.0.0/16

The IPv4 rules refuse your VPC's ranges first, then the metadata, link local, carrier grade NAT, private, 0.0.0.0/8, benchmarking (198.18.0.0/15), IETF protocol (192.0.0.0/24), multicast and reserved ranges. Everything else (the public internet, where the feeds and public registries are) is allowed:

sudo iptables -S OUTPUT | grep CLOUDIMG
sudo iptables -S CLOUDIMG-CLAIR-EGRESS | grep -E '^-A' | grep -v -- '-p udp'
-A OUTPUT -o lo -p tcp -m multiport --dports 6060,8089 -j CLOUDIMG-CLAIR-LOOPBACK
-A OUTPUT -m owner --uid-owner 997 -j CLOUDIMG-CLAIR-EGRESS
-A CLOUDIMG-CLAIR-EGRESS -d 127.0.0.0/8 -o lo -j RETURN
-A CLOUDIMG-CLAIR-EGRESS -d 172.31.0.0/16 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 172.31.0.0/16 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 169.254.0.0/16 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 169.254.0.0/16 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 100.64.0.0/10 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 100.64.0.0/10 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 10.0.0.0/8 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 10.0.0.0/8 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 172.16.0.0/12 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 172.16.0.0/12 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 192.168.0.0/16 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 192.168.0.0/16 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 0.0.0.0/8 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 0.0.0.0/8 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 168.63.129.16/32 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 168.63.129.16/32 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 198.18.0.0/15 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 198.18.0.0/15 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 192.0.0.0/24 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 192.0.0.0/24 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 224.0.0.0/4 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 224.0.0.0/4 -j REJECT --reject-with icmp-admin-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 240.0.0.0/4 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 240.0.0.0/4 -j REJECT --reject-with icmp-admin-prohibited

IPv6 is closed by default: loopback is allowed, and when the VPC lookup succeeded, global unicast addresses (2000::/3) outside your VPC's IPv6 ranges and the documentation, Teredo and 6to4 ranges are allowed; everything else, including unique local, link local and NAT64 addresses, is refused:

sudo ip6tables -S CLOUDIMG-CLAIR-EGRESS | grep -E '^-A' | grep -v -- '-p udp'
-A CLOUDIMG-CLAIR-EGRESS -d ::1/128 -o lo -j RETURN
-A CLOUDIMG-CLAIR-EGRESS -d fc00::/7 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d fc00::/7 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d fe80::/10 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d fe80::/10 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 64:ff9b::/96 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 64:ff9b::/96 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 64:ff9b:1::/48 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 64:ff9b:1::/48 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d ::ffff:0.0.0.0/96 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d ::ffff:0.0.0.0/96 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 2001::/32 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 2001::/32 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 2001:db8::/32 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 2001:db8::/32 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 2002::/16 -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -d 2002::/16 -j REJECT --reject-with icmp6-adm-prohibited
-A CLOUDIMG-CLAIR-EGRESS -d 2000::/3 -j RETURN
-A CLOUDIMG-CLAIR-EGRESS -p tcp -j REJECT --reject-with tcp-reset
-A CLOUDIMG-CLAIR-EGRESS -j REJECT --reject-with icmp6-adm-prohibited

If the metadata lookup ever fails or returns an incomplete answer (for example an interface without its VPC range), the helper records source none, the IPv6 global rule is left out so all IPv6 except loopback is refused, the IPv4 static ranges stay in force, one line in the journal says the VPC ranges are unknown, and Clair still starts and scans over IPv4. Refused connections are reset at once (REJECT), so a manifest that points at an internal address fails immediately instead of hanging. Try it as the clair user: the metadata service, a private address, an address in your VPC and a benchmarking address are refused; a feed and the public registry are reachable:

V=$(awk '$1=="4" {print $2; exit}' /run/cloudimg-clair/vpc-cidrs | cut -d/ -f1 | awk -F. '{print $1"."$2"."$3"."($4+10)}')
for t in http://169.254.169.254/latest/meta-data/ http://10.0.0.1/ "http://${V}/" http://198.18.0.1/ https://secdb.alpinelinux.org/ https://registry-1.docker.io/v2/; do
  if code=$(sudo setpriv --reuid=clair --regid=clair --clear-groups -- curl -s -m 5 -o /dev/null -w '%{http_code}' "$t"); then echo "$t -> HTTP $code"; else echo "$t -> refused"; fi
done
http://169.254.169.254/latest/meta-data/ -> refused
http://10.0.0.1/ -> refused
http://172.31.0.10/ -> refused
http://198.18.0.1/ -> refused
https://secdb.alpinelinux.org/ -> HTTP 200
https://registry-1.docker.io/v2/ -> HTTP 401

(401 from the registry is its normal answer to an anonymous request: it is reachable.) Clair runs as the unprivileged clair user. Its service also carries the same ranges, plus your VPC's, as a cgroup IPAddressDeny list (a second block that does not depend on iptables), and nginx and the first boot service cannot reach the metadata service:

ps -o user:8=,args= -C clair
systemctl show clair nginx clair-firstboot -p Id -p IPAddressDeny
clair    /opt/clair/clair -conf /etc/clair/config.yaml -mode combo
IPAddressDeny=10.0.0.0/8 168.63.129.16/32 240.0.0.0/4 64:ff9b::/96 198.18.0.0/15 ::ffff:0.0.0.0/96 2001::/32 192.168.0.0/16 fe80::/10 64:ff9b:1::/48 2002::/16 169.254.0.0/16 0.0.0.0/8 100.64.0.0/10 224.0.0.0/4 fc00::/7 2001:db8::/32 192.0.0.0/24 172.16.0.0/12
Id=clair.service

IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
Id=nginx.service

IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
Id=clair-firstboot.service

The rules are applied in one atomic iptables-restore transaction per address family: a reload replaces the chain in a single step, a reload that fails leaves the previous rules in place, and stopping the unit does not remove them. sudo systemctl reload cloudimg-clair-egress re-applies them.

Step 9: Scan images in private registries on your network

Because private and VPC addresses are refused, Clair cannot fetch layers from a registry inside your network until you allow its range. List each registry's network, one CIDR per line, in /etc/cloudimg/clair-egress-allow (root owned, not group or world writable), then reload the firewall and restart Clair. For example, for a registry at registry.<your-domain> in 10.20.30.0/24, <registry-cidr> is 10.20.30.0/24:

echo '<registry-cidr>   # registry.<your-domain>' | sudo tee /etc/cloudimg/clair-egress-allow
sudo systemctl reload cloudimg-clair-egress && sudo systemctl restart clair

Allowed ranges are checked before every refusal, including your VPC's ranges, so a registry inside your own VPC works too. Only private ranges (RFC 1918, carrier grade NAT and IPv6 unique local) can be allowed: an entry that overlaps the metadata service, link local or 0.0.0.0/8 is rejected and the previous rules stay in force. Keep the list as narrow as your registries: every allowed range is reachable by whoever can call the API.

Step 10: Rotate the passwords, use your own DNS name

sudo clair-rotate-password replaces the API password with a new random one, writes it to /root/clair-credentials.txt and reloads nginx; the old password stops working at once. Update your CI secret store afterwards. sudo clair-rotate-db-password does the same for the PostgreSQL password Clair uses (PostgreSQL only ever receives its SCRAM-SHA-256 verifier) and restarts Clair. Rotate whenever a password may have been exposed.

Point a DNS record at the instance, then set the name the API is served as. This mints a new self signed leaf certificate for the name (and the instance's IP addresses), updates nginx and the credentials file, and keeps the name across reboots. Give your clients the new certificate afterwards:

sudo clair-set-hostname <your-domain>

To use a certificate from your own certificate authority instead, copy it over the generated pair and reload nginx (running clair-set-hostname again replaces it with a self signed one):

sudo install -m 0644 <your-domain>.crt /etc/nginx/tls/clair.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/tls/clair.key
sudo nginx -t && sudo systemctl reload nginx

Memory. Clair's Go heap is held to 60% of the instance's memory: clair.service works the limit out from /proc/meminfo every time it starts, so resizing the instance needs no edit. It also clears Clair's download spool in /var/cache/clair on every start.

sudo cat /run/clair-memlimit.env
GOMEMLIMIT=9697411KiB
MemoryPeak=4043624448

Step 11: Server components

Component Path
Clair and clairctl /opt/clair/clair, /opt/clair/clairctl (read only, owner root)
Licence and build provenance /usr/share/doc/clair/
Configuration /etc/clair/config.yaml (no secrets; the database password is in /etc/clair/pgpass, readable by Clair only)
Database PostgreSQL 16 cluster 16/main on the data volume at /var/lib/postgresql
Service units clair.service, clair-firstboot.service, cloudimg-clair-egress.service, cloudimg-clair-vpc-cidrs.service in /etc/systemd/system/
Egress rules /usr/local/sbin/clair-egress-apply (--print shows the ruleset), /usr/local/sbin/clair-vpc-cidrs (--print shows what the metadata lookup finds)
nginx site and password file /etc/nginx/sites-available/cloudimg-clair, /etc/nginx/clair.htpasswd
Certificate and key /etc/nginx/tls/
Helpers /usr/local/sbin/clair-scan, clair-rotate-password, clair-rotate-db-password, clair-set-hostname, clair-guard
Credentials /root/clair-credentials.txt (mode 0600)
/opt/clair/clairctl --version
grep -E '^(source|toolchain|changes)' /usr/share/doc/clair/BUILD-PROVENANCE.txt
nginx -v 2>&1; psql --version
clairctl version v4.9.0 (claircore v1.5.57)
source:    https://github.com/quay/clair/releases/download/v4.9.0/clair-v4.9.0.tar.gz
toolchain: go1.27.3 linux/amd64 (https://go.dev/dl/go1.27.3.linux-amd64.tar.gz, sha256 65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40); build time only, not shipped
changes:   dependency raise only (go.mod, no source patch; vendor/ dropped): github.com/jackc/pgx/v5@v5.10.0 google.golang.org/grpc@v1.83.2 golang.org/x/net@v0.62.0 github.com/klauspost/compress@v1.20.2 github.com/go-jose/go-jose/v3@v3.0.5 go.opentelemetry.io/
nginx version: nginx/1.24.0 (Ubuntu)
psql (PostgreSQL) 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)

The settings that matter most:

sudo grep -nE '^(http_listen_addr|introspection_addr|  period|  connstring)' /etc/clair/config.yaml
8:http_listen_addr: "127.0.0.1:6060"
9:introspection_addr: "127.0.0.1:8089"
12:  connstring: "host=127.0.0.1 port=5432 user=clair dbname=clair sslmode=disable passfile=/etc/clair/pgpass pool_max_conns=10"
17:  connstring: "host=127.0.0.1 port=5432 user=clair dbname=clair sslmode=disable passfile=/etc/clair/pgpass pool_max_conns=20"
20:  period: 6h
22:  connstring: "host=127.0.0.1 port=5432 user=clair dbname=clair sslmode=disable passfile=/etc/clair/pgpass pool_max_conns=5"

Clair runs in combo mode (indexer, matcher and notifier in one process). The connection pools are sized in the connection strings (pool_max_conns); the updaters need two connections each while they store a feed. Change a setting by editing /etc/clair/config.yaml with the names from the Clair configuration reference, then sudo systemctl restart clair. The notifier has no delivery method configured; add a webhook, AMQP or STOMP block there if you want notifications when new vulnerabilities affect images you have indexed. Logs: sudo journalctl -u clair for Clair, /var/log/nginx/access.log for API requests (with the real client address).

Step 12: Security recommendations

  • Allow port 22 in the security group from your own networks only, and port 443 only from the networks of your CI runners, registries and developers.
  • Keep the API password in your CI system's secret store, and rotate it (Step 10) when someone leaves or it may have been exposed.
  • Keep the egress allow-list (Step 9) as narrow as your private registries.
  • Use your own DNS name and certificate (Step 10).
  • Back up /var/lib/postgresql (an EBS snapshot of the data volume is simplest) if you rely on the stored index reports; the vulnerability data itself is re-downloaded by the updaters.
  • Keep the operating system patched; unattended security upgrades are on, and sudo apt-get update && sudo apt-get upgrade applies everything else.

Support and licensing

Clair is open source under the Apache License 2.0; there is no per user or per CPU licence fee. The licence and notice are in /usr/share/doc/clair/. cloudimg is not affiliated with or endorsed by the Quay or Clair projects or Red Hat. The AMI price covers cloudimg's hardening, first boot setup and support.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA