A practical, hands-on guide to securing Docker images. Every section is a terminal walkthrough, you run the commands, you see the results.
Covers base image selection, multi-stage builds, dependency scanning, image hardening, secrets detection, and CI/CD pipeline integration across Go, Python, Node.js, and Java.
Install the tools before starting:
# Docker
curl -fsSL https://get.docker.com | sh
# Trivy (vulnerability scanner)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Hadolint (Dockerfile linter)
wget -O /usr/local/bin/hadolint https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64
chmod +x /usr/local/bin/hadolint
# Grype (alternative image scanner)
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Syft (SBOM generator)
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/binVerify:
docker --version
trivy --version
hadolint --version
grype version
syft versiongit clone https://github.com/bogdanticu88/building-vulnerability-free-Docker-images-with-examples-and-CI-CD-pipeline.git
cd building-vulnerability-free-Docker-images-with-examples-and-CI-CD-pipelineExplore the structure:
find . -type f | sort./ci-cd/github-actions/scan-image.yml
./ci-cd/gitlab-ci/.gitlab-ci.yml
./docs/GETTING_STARTED.md
./docs/SECURITY_CHECKLIST.md
./examples/go/Dockerfile
./examples/java/Dockerfile
./examples/nodejs/Dockerfile
./examples/python/Dockerfile
Pull a full Ubuntu image and scan it immediately:
docker pull ubuntu:latest
trivy image ubuntu:latest --severity HIGH,CRITICALCompare with a minimal Alpine image:
docker pull alpine:3.19
trivy image alpine:3.19 --severity HIGH,CRITICALThe vulnerability count difference is significant. Alpine is minimal by design — fewer packages means fewer attack vectors.
Try distroless (no shell, no package manager):
docker pull gcr.io/distroless/static-debian12
trivy image gcr.io/distroless/static-debian12 --severity HIGH,CRITICALRule: start with the smallest base that your app can run on.
Hadolint catches security issues and best-practice violations before you build anything.
# Lint a single Dockerfile
hadolint examples/python/Dockerfile
# Lint all Dockerfiles at once
find examples -name Dockerfile | xargs -I {} hadolint {}
# Strict mode — treat warnings as errors
hadolint --failure-threshold warning examples/go/DockerfileCommon issues Hadolint flags:
- Using
latesttag instead of a pinned version apt-get updateandapt-get installin separateRUNlayers- Using
ADDinstead ofCOPY - Missing
--no-cacheonapk add - Container running as root
Each example uses multi-stage builds to keep the final image small.
# Go — binary on Alpine (~15MB)
docker build -f examples/go/Dockerfile -t secure-go:latest examples/go/
# Python — slim with user-installed deps (~90MB)
docker build -f examples/python/Dockerfile -t secure-python:latest examples/python/
# Node.js — Alpine with production deps only (~60MB)
docker build -f examples/nodejs/Dockerfile -t secure-nodejs:latest examples/nodejs/
# Java — distroless runtime (~250MB)
docker build -f examples/java/Dockerfile -t secure-java:latest examples/java/Check image sizes:
docker images | grep -E "REPOSITORY|secure-"Compare to full-stack equivalents. The Go binary will be around 15MB vs 1.2GB with build tools included.
# Scan and display all findings
trivy image secure-python:latest
# Show only HIGH and CRITICAL
trivy image --severity HIGH,CRITICAL secure-python:latest
# Exit with code 1 if any HIGH or CRITICAL found (CI gate)
trivy image --severity HIGH,CRITICAL --exit-code 1 secure-python:latestScan all four at once:
for img in secure-go secure-python secure-nodejs secure-java; do
echo "=== $img ==="
trivy image --severity HIGH,CRITICAL "$img:latest"
doneUse Grype as a second opinion:
grype secure-python:latest
grype secure-go:latestExport a JSON report for audit purposes:
trivy image --format json --output trivy-report.json secure-python:latest
# List only CRITICAL CVEs
cat trivy-report.json | jq '.Results[].Vulnerabilities[]? | select(.Severity == "CRITICAL") | .VulnerabilityID'An SBOM lists every package inside your image. Required for supply chain compliance and audits.
# SPDX format
syft secure-python:latest -o spdx-json > sbom-python.spdx.json
# CycloneDX format
syft secure-go:latest -o cyclonedx-json > sbom-go.cyclonedx.json
# Inspect package list
cat sbom-python.spdx.json | jq '.packages[].name' | head -20Feed the SBOM back into Grype:
grype sbom:sbom-python.spdx.json# Python
docker run --rm secure-python:latest whoami
# Expected: appuser
# Go
docker run --rm secure-go:latest whoami
# Expected: appuser
# Confirm UID
docker run --rm secure-python:latest id
# Expected: uid=1000(appuser) gid=1000(appuser)If you see root or uid=0, the Dockerfile is missing a USER instruction.
Run with a read-only root filesystem:
docker run --rm --read-only secure-go:latest
docker run --rm --read-only secure-python:latest
# If the app writes to /tmp, allow that mount only
docker run --rm --read-only --tmpfs /tmp secure-python:latestAny write attempt outside /tmp will fail — which is exactly what you want.
Containers get a default set of Linux capabilities. Remove everything that isn't needed:
# Drop all capabilities
docker run --rm --cap-drop ALL secure-go:latest
# Add back only what the app requires
docker run --rm --cap-drop ALL --cap-add NET_BIND_SERVICE secure-nodejs:latest
# Prevent privilege escalation
docker run --rm --security-opt no-new-privileges secure-python:latest
# Apply resource limits
docker run --rm --memory 512m --cpus 1 secure-java:latestCheck that no credentials, tokens, or keys ended up in the image or repository:
# Scan the image filesystem for secrets
trivy image --scanners secret secure-python:latest
trivy image --scanners secret secure-nodejs:latest
# Scan repository files
docker run --rm -v "$PWD:/src" trufflesecurity/trufflehog:latest filesystem /srcImage signing proves the image hasn't been tampered with between build and deployment.
# Install Cosign
curl -O -L https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x cosign-linux-amd64
sudo mv cosign-linux-amd64 /usr/local/bin/cosign
# Generate a key pair
cosign generate-key-pair
# Produces: cosign.key (private), cosign.pub (public)
# Sign the image (must be pushed to a registry first)
cosign sign --key cosign.key your-registry/secure-go:1.0.0
# Verify
cosign verify --key cosign.pub your-registry/secure-go:1.0.0Run this sequence before any image goes to a registry:
IMAGE=myregistry.io/secure-go:1.0.0
# 1. Lint Dockerfile
hadolint examples/go/Dockerfile
# 2. Build
docker build -f examples/go/Dockerfile -t $IMAGE examples/go/
# 3. Scan — stops here if HIGH or CRITICAL found
trivy image --severity HIGH,CRITICAL --exit-code 1 $IMAGE
# 4. Scan for secrets
trivy image --scanners secret $IMAGE
# 5. Generate SBOM
syft $IMAGE -o cyclonedx-json > sbom.json
# 6. Login and push
docker login myregistry.io
docker push $IMAGE
# 7. Sign
cosign sign --key cosign.key $IMAGEIf step 3 fails, stop. Do not push.
Ready-to-use pipeline configurations are in the ci-cd/ directory:
| Platform | File |
|---|---|
| GitHub Actions | ci-cd/github-actions/scan-image.yml |
| GitLab CI | ci-cd/gitlab-ci/.gitlab-ci.yml |
Both pipelines enforce the same sequence:
- Lint Dockerfile with Hadolint
- Build the image
- Scan with Trivy — fail on HIGH/CRITICAL
- Scan for secrets with TruffleHog
- Push to registry only if all checks pass
Copy the GitHub Actions workflow into your project:
mkdir -p .github/workflows
cp ci-cd/github-actions/scan-image.yml .github/workflows/
git add .github/workflows/scan-image.yml
git commit -m "Add Docker security scanning pipeline"
git push| Language | Base Image | Approx. Size | Notes |
|---|---|---|---|
| Go | alpine:3.18 |
~15MB | CGO disabled, binary stripped |
| Python | python:3.11-slim |
~90MB | Deps installed as user |
| Node.js | node:20-alpine |
~60MB | Production deps only |
| Java | distroless/java21 |
~250MB | No shell, no package manager |
Every Dockerfile uses: multi-stage build, non-root user, pinned base image, and a HEALTHCHECK.
See docs/SECURITY_CHECKLIST.md before deploying any image to production.