Control panels

Securing Docker images against vulnerabilities and malware

Scan Docker images for known CVEs and malware, then harden the build and the runtime so a compromised container cannot spread across your VPS.

IntermediateLinux server7 min read

You will scan your Docker images for known vulnerabilities and malware, then harden how those images are built and run. The goal is simple: a bad image should fail your scan before it reaches a public VPS, and a compromised container should not be able to do much once it is running.

This guide uses Trivy, Grype and Docker Scout for scanning, plus a hardened docker run command for runtime. Everything runs on a normal Linux VPS with Docker already installed.

Before you start

  • A Linux VPS with Docker installed. If you have not set it up yet, follow Install Docker and Compose on a Linux VPS.
  • A user in the docker group, or root. If you see permission denied on the socket, see Fix "permission denied" on the Docker socket.
  • An image to test. Use your own build, or pull a public one such as nginx:latest to practise on.
  • Outbound HTTPS from the VPS. Scanners download a vulnerability database on first run, so a fully locked-down firewall will break them.

Tip: Run scans on your build machine or a staging VPS, not on production. Scanning pulls large databases and adds CPU load.

You manage the server itself from your VPS provider's control panel: IP, username, password, power controls, OS reinstall and a browser console. If you need a fresh machine for testing, that is where you rebuild it.

Scan Docker images for vulnerabilities

Trivy is the quickest scanner to start with because you can run it as a container and skip installation entirely.

Bash
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image myapp:latest

The first run downloads the vulnerability database, which takes a minute or two. After that you get a table of packages, the CVE identifier, severity and whether a fixed version exists.

Filter to what actually matters:

Bash
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image --severity HIGH,CRITICAL --ignore-unfixed myapp:latest

--ignore-unfixed hides issues with no available patch, so you are left with a list you can act on today. Add --exit-code 1 to make the command fail when anything is found — that is what you want in CI, so a bad image never gets pushed.

Grype is a good second opinion with a different database:

Bash
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  anchore/grype:latest myapp:latest

If your Docker CLI is recent, Docker Scout is built in:

Bash
docker scout quickview myapp:latest
docker scout cves myapp:latest

Note: Scanner databases and CLI flags change often. Check the vendor's own documentation for the current syntax before you wire a scanner into a pipeline.

Harden the image at build time

Most vulnerabilities come from the base image and from packages you did not need. A multi-stage build with a small runtime base removes both. See Smaller Docker images with multi-stage builds for the full technique.

Dockerfile
FROM node:alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM node:alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build --chown=app:app /app /app
USER app
EXPOSE 3000
CMD ["node", "server.js"]

Four rules that do most of the work:

  1. Use a minimal base (alpine, slim, or a distroless image) instead of a full distribution.
  2. Copy only build output into the final stage, never compilers or package caches.
  3. Create a dedicated user and switch to it with USER before CMD.
  4. Add a .dockerignore so .git, .env and local build folders never enter a layer.

Warning: Never bake secrets into an image. ENV API_KEY=... and COPY.env are readable by anyone who pulls the image, even if you delete the file in a later layer. Pass secrets at runtime with -e or a mounted file.

Pin the base image by digest so a rebuild cannot silently pull a different image:

Bash
docker pull node:alpine
docker inspect --format='{{index .RepoDigests 0}}' node:alpine

Then use FROM node@sha256:... in your Dockerfile.

Harden the container at runtime

A hardened image still runs as root inside the container unless you say otherwise. These flags remove most of the damage a compromised process could do:

Bash
docker run -d --name myapp \
  --read-only --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  --user 1000:1000 \
  -p 8080:3000 \
  --restart unless-stopped \
  myapp:latest
  • --read-only makes the container filesystem immutable. Anything the app must write goes to a volume or a --tmpfs mount.
  • --cap-drop ALL removes Linux capabilities the process does not need.
  • --security-opt no-new-privileges:true blocks setuid binaries from escalating.
  • --user 1000:1000 forces a non-root UID even if the image forgot to set one.

If your app stores uploads or a database, mount a volume instead of writing into the container. Docker volumes vs bind mounts explains the difference, and Docker persistent storage for non-root users covers the ownership problems that follow.

Add CPU and memory limits too, so a runaway or malicious process cannot take the whole VPS down. Limit Docker container CPU and memory has the flags.

Verify image integrity and scan for malware

Vulnerability scanning checks known CVEs. It does not tell you whether the image is the one you built, or whether a mounted upload folder contains malware.

For integrity, compare the digest you pulled against the one you published:

Bash
docker inspect --format='{{index .RepoDigests 0}}' myapp:latest

If you sign images with Sigstore Cosign, verify the signature before deploying:

Bash
cosign verify --key cosign.pub ghcr.io/example/myapp:latest

For files that arrive from users — uploads, imports, mail attachments — scan the volume on the host with ClamAV:

Bash
sudo apt-get install -y clamav
sudo freshclam
clamscan -r --infected /var/lib/docker/volumes/uploads/_data

Warning: freshclam needs a few minutes on first run to download signatures. A scan started before the database is ready will report nothing useful.

Check it works

Confirm the hardening actually applied:

Bash
docker exec myapp id
docker inspect --format='{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}}' myapp

The first command should print a non-root UID. The second should show [ALL] true.

Then re-scan the running image and confirm the count went down:

Bash
docker scout quickview myapp:latest

Finally, check the container is healthy and reachable on the port you published:

Bash
curl -I http://203.0.113.10:8080
docker logs --tail 50 myapp

Troubleshooting

  • Trivy reports hundreds of CVEs in the base image → the base is old or too large. Rebuild on a current minimal base, pin it by digest, and rebuild on a schedule rather than once.
  • Container exits immediately after adding --read-only → the app writes to a path inside the image. Add --tmpfs /tmp or mount a volume for the directory it needs, then check Fix a container that exits immediately.
  • permission denied after switching to USER app → files were copied as root. Use COPY --chown=app:app in the Dockerfile, or fix ownership on the mounted volume.
  • docker scout not found → the plugin is missing or your CLI is older. Update the Docker CLI, or use Trivy and Grype instead.
  • Scanner fails to download its database → outbound HTTPS is blocked. Allow access to the scanner's database host, or run an offline scan against a cached database.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.