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
dockergroup, or root. If you seepermission deniedon 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:latestto 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.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image myapp:latestThe 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:
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:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
anchore/grype:latest myapp:latestIf your Docker CLI is recent, Docker Scout is built in:
docker scout quickview myapp:latest
docker scout cves myapp:latestNote: 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.
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:
- Use a minimal base (
alpine,slim, or a distroless image) instead of a full distribution. - Copy only build output into the final stage, never compilers or package caches.
- Create a dedicated user and switch to it with
USERbeforeCMD. - Add a
.dockerignoreso.git,.envand 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:
docker pull node:alpine
docker inspect --format='{{index .RepoDigests 0}}' node:alpineThen 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:
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-onlymakes the container filesystem immutable. Anything the app must write goes to a volume or a--tmpfsmount.--cap-drop ALLremoves Linux capabilities the process does not need.--security-opt no-new-privileges:trueblocks setuid binaries from escalating.--user 1000:1000forces 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:
docker inspect --format='{{index .RepoDigests 0}}' myapp:latestIf you sign images with Sigstore Cosign, verify the signature before deploying:
cosign verify --key cosign.pub ghcr.io/example/myapp:latestFor files that arrive from users — uploads, imports, mail attachments — scan the volume on the host with ClamAV:
sudo apt-get install -y clamav
sudo freshclam
clamscan -r --infected /var/lib/docker/volumes/uploads/_dataWarning: 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:
docker exec myapp id
docker inspect --format='{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}}' myappThe 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:
docker scout quickview myapp:latestFinally, check the container is healthy and reachable on the port you published:
curl -I http://203.0.113.10:8080
docker logs --tail 50 myappTroubleshooting
- 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 /tmpor mount a volume for the directory it needs, then check Fix a container that exits immediately. permission deniedafter switching toUSER app→ files were copied as root. UseCOPY --chown=app:appin the Dockerfile, or fix ownership on the mounted volume.docker scoutnot 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
- Docker security checklist for a public VPS covers the host side: firewall, socket exposure and daemon settings.
- Update Docker containers safely keeps patched base images flowing into production.
- Push images to Docker Hub or GitHub Container Registry shows how to publish digests you can pin and verify.
- Docker basics: containers, images and registries if any of the terminology above was new.
