Overview
This guide extracts real container security patterns from 13 open-source projects — from Dockerfile design to CI pipeline scanning to image signing. Every code snippet comes from a production codebase we have analyzed. Rather than theoretical best practices, this is a field guide to what actually works at scale.
The projects span languages (Go, Python, Node.js, Java) and deployment models (CLI tools, web applications, security scanners, infrastructure platforms), giving us a broad view of how container security is implemented in practice.
Projects analyzed: Threat Dragon, OpenTofu, oCIS, Wazuh, Gitleaks, Insomnia, Grype, dep-scan, Checkov, ZAP, Traefik
The Base Image Spectrum
The single most impactful container security decision is your FROM line. Across our codebases, we see the full spectrum:
FROM scratch — Absolute Zero
Grype and OpenTofu use scratch as their base image. This means the container contains literally nothing except what you explicitly copy in — no shell, no package manager, no libc.
# Grype - Dockerfile
FROM gcr.io/distroless/static-debian12:latest AS build
FROM scratch
# needed for version check HTTPS request
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# create the /tmp dir, which is needed for image content cache
WORKDIR /tmp
COPY grype /
ENTRYPOINT ["/grype"]# OpenTofu - Dockerfile.minimal
FROM scratch
LABEL maintainer="OpenTofu Core Team <core@opentofu.org>"
COPY tofu /usr/local/bin/tofu
ENTRYPOINT ["/usr/local/bin/tofu"]When to use: Compiled static binaries (Go, Rust) that don’t need libc. Grype copies only CA certs and the binary. OpenTofu copies only the binary. Attack surface is near zero.
FROM distroless — Minimal Runtime
Grype offers three variants using Google’s distroless images:
# Grype - Dockerfile.nonroot
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /tmp
COPY grype /
ENTRYPOINT ["/grype"]# Grype - Dockerfile.debug
FROM gcr.io/distroless/static-debian12:debug-nonroot
WORKDIR /tmp
COPY grype /
ENTRYPOINT ["/grype"]The nonroot variant runs as UID 65532 by default. The debug-nonroot variant includes a shell for troubleshooting. Both are built on Debian 12 but contain only the minimal runtime dependencies.
When to use: When you need CA certs, timezone data, or a minimal runtime but want to avoid a shell in production.
FROM alpine — Small but Capable
Traefik and Gitleaks use Alpine for their runtime images:
# Traefik - Dockerfile
# syntax=docker/dockerfile:1.2
FROM alpine:3.23
RUN apk add --no-cache --no-progress ca-certificates tzdata
ARG TARGETPLATFORM
COPY ./dist/$TARGETPLATFORM/traefik /
EXPOSE 80
VOLUME ["/tmp"]
ENTRYPOINT ["/traefik"]# Gitleaks - Dockerfile
FROM golang:1.24 AS build
WORKDIR /go/src/github.com/zricethezav/gitleaks
COPY . .
RUN VERSION=$(git describe --tags --abbrev=0) && \
CGO_ENABLED=0 go build -o bin/gitleaks -ldflags "-X=...version.Version=${VERSION}"
FROM alpine:3.22
RUN apk add --no-cache bash git openssh-client
COPY --from=build /go/src/github.com/zricethezav/gitleaks/bin/* /usr/bin/
RUN git config --global --add safe.directory '*'
ENTRYPOINT ["gitleaks"]When to use: When your application needs shell access, git, or other common tools. Alpine uses musl libc instead of glibc — test thoroughly if your app has C dependencies.
FROM slim — Full Runtime, Smaller Footprint
Checkov uses Debian slim:
# Checkov - Dockerfile
FROM python:3.11-slim
ENV RUN_IN_DOCKER=True
RUN set -eux; \
apt-get update; \
apt-get -y upgrade; \
apt-get install -y --no-install-recommends \
ca-certificates git curl openssh-client; \
pip install setuptools==78.1.1 urllib3==2.2.2; \
curl -sSLo get_helm.sh https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3; \
chmod 700 get_helm.sh; VERIFY_CHECKSUM=true ./get_helm.sh; \
...
apt-get remove -y curl; \
apt-get purge -y --auto-remove; \
rm -rf /var/lib/apt/lists/*Note how Checkov installs curl, uses it, then removes it. The final image doesn’t contain the download tool.
FROM enterprise-minimal — Full Language Runtimes
dep-scan needs Python, Java, Go, Node.js, and PHP in a single image:
# dep-scan - Dockerfile
FROM ghcr.io/almalinux/10-minimal:10
ARG JAVA_VERSION=26.0.1-tem
ARG MAVEN_VERSION=3.9.15
ARG PYTHON_VERSION=3.12
ARG GO_VERSION=1.26.3
...This is the heavyweight end of the spectrum — necessary when your tool is a multi-language scanner, but it significantly increases the attack surface.
Decision Framework
- Static binary (Go/Rust) →
scratchordistroless- Need shell access →
alpine- Need glibc or specific system libs →
*-slim- Multi-language runtime → enterprise minimal (AlmaLinux, Ubuntu minimal)
Declarative Image Builds with apko
minimal takes a fundamentally different approach to container image construction: instead of writing Dockerfiles, it uses apko — Chainguard’s declarative image builder. apko reads a YAML config and produces an OCI image directly, with no Dockerfile, no Docker daemon, and no shell in the build process.
How apko Replaces Dockerfiles
A traditional Dockerfile for a Python image:
FROM python:3.14-alpine
RUN adduser -D -u 65532 nonroot
WORKDIR /app
USER nonroot
ENTRYPOINT ["python3"]The equivalent apko configuration:
# python/apko/python.yaml
contents:
repositories:
- https://packages.wolfi.dev/os
keyring:
- https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
packages:
- wolfi-baselayout
- python-3.14
- glibc
- glibc-locale-posix
- ld-linux
- libgcc
- libstdc++
- libffi
- zlib
- libssl3
- libcrypto3
- readline
- ncurses
- ncurses-terminfo-base
- libexpat1
- libbz2-1
- xz
- libzstd1
- mpdecimal
- sqlite-libs
- gdbm
- libuuid
- ca-certificates-bundle
accounts:
groups:
- groupname: nonroot
gid: 65532
users:
- username: nonroot
uid: 65532
gid: 65532
run-as: 65532
entrypoint:
command: /usr/bin/python3
work-dir: /app
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PYTHONHASHSEED: random
LANG: C.UTF-8
PATH: /usr/bin:/bin
paths:
- path: /app
type: directory
uid: 65532
gid: 65532
permissions: 0o755
- path: /tmp
type: directory
uid: 65532
gid: 65532
permissions: 0o1777
annotations:
org.opencontainers.image.title: "minimal-python"
org.opencontainers.image.description: "Hardened shell-less Python 3 image built on Wolfi with daily CVE patches"
org.opencontainers.image.source: "https://github.com/rtvkiz/minimal/tree/main/python"
org.opencontainers.image.licenses: "Apache-2.0"
archs:
- x86_64
- aarch64Key differences from Dockerfiles:
- No shell in the build. apko assembles the filesystem from packages — there is no
RUNstep, no shell execution, no chance for supply chain injection during build - Every package is explicit. Unlike a Dockerfile where
apk add python3pulls in transitive dependencies invisibly, apko requires listing every package. This is the container equivalent of a lockfile - Non-root is declarative. User/group configuration is a first-class YAML section, not scattered
RUN addusercommands - Multi-arch is a config field.
archs: [x86_64, aarch64]— no QEMU, nodocker buildx, no platform matrix in CI - OCI annotations are native. Image metadata lives in the config, not in separate
LABELdirectives
Building from Source with melange
For applications not available as Wolfi packages, rtvkiz/minimal uses melange to build from source:
# redis-slim/melange.yaml
package:
name: redis-minimal
version: 8.6.3
epoch: 0
description: "Minimal Redis server and CLI built from source"
copyright:
- license: SSPL-1.0
vars:
sha256: 58d0d1eb49a1ea6c2179659707fec171b1e2e2b8d5157ed2ec59d1d66ad5a654
environment:
contents:
repositories:
- https://packages.wolfi.dev/os
keyring:
- https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
packages:
- busybox
- ca-certificates-bundle
- curl
- build-base
- linux-headers
- openssl-dev
- jemalloc-dev
pipeline:
- runs: |
curl -fsSL --retry 5 --retry-all-errors "https://github.com/redis/redis/archive/refs/tags/${{package.version}}.tar.gz" -o /home/build/redis.tar.gz
echo "${{vars.sha256}} /home/build/redis.tar.gz" | sha256sum -c -
- runs: |
cd /home/build/redis-${{package.version}}
make -j$(nproc) BUILD_TLS=yes USE_SYSTEMD=no MALLOC=jemalloc
make install PREFIX=/home/build/redis-install
- runs: |
mkdir -p "${{targets.destdir}}/usr/bin"
cp /home/build/redis-install/bin/redis-server "${{targets.destdir}}/usr/bin/"
cp /home/build/redis-install/bin/redis-cli "${{targets.destdir}}/usr/bin/"
strip --strip-unneeded "${{targets.destdir}}/usr/bin/redis-server"
strip --strip-unneeded "${{targets.destdir}}/usr/bin/redis-cli"The melange pipeline downloads source with SHA256 verification, builds with minimal flags, strips debug symbols, and installs only the needed binaries. The resulting .apk package is then consumed by apko.
The apko config for this source-built image references the local melange package:
# redis-slim/apko/redis.yaml
contents:
repositories:
- https://packages.wolfi.dev/os
# Local melange-built packages (passed via --repository-append)
keyring:
- https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
# Local signing key passed via --keyring-append
packages:
- wolfi-baselayout
- redis-minimal # Built from source via melange
- glibc
- glibc-locale-posix
- ld-linux
- libgcc
- libstdc++
- libssl3
- libcrypto3
- libjemalloc2
- ca-certificates-bundle
accounts:
groups:
- groupname: redis
gid: 65532
users:
- username: redis
uid: 65532
gid: 65532
run-as: 65532
entrypoint:
command: /usr/bin/redis-serverThis two-stage pattern (melange builds packages, apko assembles image) provides the flexibility of source compilation with the reproducibility of declarative configuration.
When to Use apko vs Dockerfile
- Use apko when your app is available as packages (Wolfi, Alpine) — you get reproducible, shell-less builds with built-in SBOM generation
- Use melange + apko when you need to build from source but still want declarative image assembly
- Use Dockerfile when you need complex build-time logic, build arguments, or tooling that doesn’t fit the melange pipeline model
Multi-Stage Build Patterns
Multi-stage builds are the single most important Dockerfile technique for security. They ensure build-time dependencies never reach the runtime image.
The 4-Stage Pattern: Threat Dragon
Threat Dragon has the most sophisticated multi-stage build in our analysis:
ARG NODE_VERSION=24.15.0
# Stage 1: Hardened base with system updates and non-root user
FROM node:$NODE_VERSION-alpine AS base-node
RUN apk -U upgrade
WORKDIR /app
RUN npm i -g npm@latest
RUN chown -R node:node /app
USER node
# Stage 2: Build front-end and back-end with devDependencies
FROM base-node AS build
RUN mkdir -p boms td.server td.vue td.vue/src/service/schema/api_json
COPY package-lock.json package.json /app/
COPY ./td.server/package-lock.json ./td.server/package.json ./td.server/
COPY ./td.vue/package-lock.json ./td.vue/package.json ./td.vue/
RUN npm clean-install --ignore-scripts
RUN cd td.server && npm clean-install
RUN cd td.vue && npm clean-install --legacy-peer-deps
RUN npm run build
# Generate SBOMs during build
RUN cd td.server && npm run make-sbom
RUN cp td.server/sbom.json boms/threat-dragon-server-bom.json && \
cp td.server/sbom.xml boms/threat-dragon-server-bom.xml && \
cp td.vue/dist/.sbom/bom.json boms/threat-dragon-site-bom.json && \
cp td.vue/dist/.sbom/bom.xml boms/threat-dragon-site-bom.xml
# Stage 3: Separate docs build (Ruby/Jekyll - completely isolated)
FROM ruby:4.0-slim-bookworm AS build-docs
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /td.docs
COPY docs/Gemfile Gemfile
COPY docs/Gemfile.lock Gemfile.lock
RUN bundle install
COPY docs/ .
RUN bundle exec jekyll build -b docs/
# Stage 4: Production image - only runtime deps
FROM base-node
COPY --chown=node:node --from=build-docs /td.docs/_site /app/docs
COPY --chown=node:node --from=build /app/boms /app/boms
COPY --chown=node:node ./td.server/package-lock.json ./td.server/package.json ./td.server/
RUN cd td.server && npm clean-install --omit dev --ignore-scripts
COPY --chown=node:node --from=build /app/td.server/dist ./td.server/dist
COPY --chown=node:node --from=build /app/td.vue/dist ./dist
COPY --chown=node:node ./td.server/index.js ./td.server/index.js
HEALTHCHECK --interval=10s --timeout=2s --start-period=2s \
CMD ["/nodejs/bin/node", "./td.server/dist/healthcheck.js"]
CMD ["td.server/index.js"]Key observations:
- Stage 1 creates a hardened base with system updates and non-root user — reused by stages 2 and 4
- Stage 2 builds the application AND generates SBOMs — the SBOMs are then copied to the final image
- Stage 3 builds documentation in a completely isolated Ruby environment — Jekyll never touches the Node.js stages
- Stage 4 reinstalls dependencies with
--omit dev— devDependencies from stage 2 are excluded
The Build Secrets Pattern: ZAP
ZAP uses Docker build secrets for sensitive download URLs:
# ZAP - Dockerfile-stable
FROM --platform=linux/amd64 debian:bookworm-slim AS builder
RUN apt-get update && apt-get install -q -y --fix-missing \
wget curl openjdk-17-jdk xmlstarlet unzip && \
rm -rf /var/lib/apt/lists/*
WORKDIR /zap
RUN wget -qO- https://raw.githubusercontent.com/.../ZapVersions.xml | \
xmlstarlet sel -t -v //url | grep -i Linux | \
wget --content-disposition -i - -O - | tar zxv && \
mv ZAP*/* . && rm -R ZAP*
# Webswing setup with build secret
ENV WEBSWING_VERSION=24.2.2
RUN --mount=type=secret,id=webswing_url \
if [ -s /run/secrets/webswing_url ]; \
then curl -s -L "$(cat /run/secrets/webswing_url)-${WEBSWING_VERSION}-distribution.zip" > webswing.zip; \
else curl -s -L "https://dev.webswing.org/files/public/...-eval-${WEBSWING_VERSION}-distribution.zip" > webswing.zip; \
fi && unzip webswing.zip && rm webswing.zipThe --mount=type=secret mounts a secret during build that is never baked into the image layers. The fallback URL is used when the secret isn’t provided.
The Read-Only Runtime Pattern: dep-scan
dep-scan uses a single-stage build but enforces runtime immutability:
# dep-scan - Dockerfile (excerpt)
COPY . /opt/dep-scan
RUN set -e; \
... install all runtimes ... \
&& cd /opt/dep-scan \
&& uv sync --all-extras --all-packages --no-dev \
&& uv cache clean \
&& depscan --help \
&& chown -R owasp:owasp /opt \
&& chmod a-w -R /opt \
&& rm -rf /var/cache/yum \
&& microdnf clean all
USER owasp
WORKDIR /app
CMD ["depscan"]chmod a-w -R /opt removes all write permissions from the application directory at the OS level. Even if an attacker gains execution in the container, they cannot modify the application files.
Non-Root User Enforcement
Running containers as root is the most common container security anti-pattern. Our analysis shows two distinct approaches:
Explicit User Creation: oCIS
oCIS creates a dedicated user with specific UID/GID and restrictive permissions:
# oCIS - Dockerfile.linux.amd64
FROM amd64/alpine:3.23.4
RUN addgroup -g 1000 -S ocis-group && \
adduser -S --ingroup ocis-group --uid 1000 ocis-user --home /var/lib/ocis
RUN mkdir -p /var/lib/ocis && \
chown -R ocis-user:ocis-group /var/lib/ocis && \
chmod -R 751 /var/lib/ocis && \
mkdir -p /etc/ocis && \
chown -R ocis-user:ocis-group /etc/ocis && \
chmod -R 751 /etc/ocis
VOLUME ["/var/lib/ocis", "/etc/ocis"]
WORKDIR /var/lib/ocis
USER 1000
EXPOSE 9200/tcpThe 751 permissions mean: owner can read/write/execute, group can read/execute, others can only execute (traverse directories). This is more restrictive than the typical 755.
Built-In Image User: Threat Dragon
Threat Dragon leverages the node user that comes with the official Node.js image:
FROM node:$NODE_VERSION-alpine AS base-node
RUN apk -U upgrade
WORKDIR /app
RUN npm i -g npm@latest
RUN chown -R node:node /app
USER nodeEvery COPY in later stages uses --chown=node:node:
COPY --chown=node:node --from=build /app/td.server/dist ./td.server/dist
COPY --chown=node:node --from=build /app/td.vue/dist ./distDistroless Non-Root: Grype
Grype’s nonroot variant uses UID 65532, which is baked into the distroless base image:
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /tmp
COPY grype /
ENTRYPOINT ["/grype"]No user creation needed — the base image already runs as non-root.
User with Password: ZAP
ZAP creates a user and sets a password (needed for VNC access in the full image):
RUN useradd -u 1000 -d /home/zap -m -s /bin/bash zap
RUN echo zap:zap | chpasswd
RUN mkdir /zap && chown zap:zap /zap
USER zapAnti-Pattern: Projects Without Non-Root
Several projects in our analysis run as root by default:
- OpenTofu — standard Dockerfile uses
alpinewith no USER directive- Traefik — single-stage, no USER
- Checkov — no USER directive
- Gitleaks — no USER directive
For CLI tools like these, the risk is lower since they’re typically run as ephemeral commands. But for long-running services, always enforce non-root.
SBOM Generation and Attestation
Software Bill of Materials (SBOM) generation is increasingly required for supply chain security. Our projects use three distinct approaches:
In-Build SBOM: Threat Dragon
Threat Dragon generates SBOMs during the Docker build and bakes them into the final image:
FROM base-node AS build
...
RUN cd td.server && npm run make-sbom
RUN cp td.server/sbom.json boms/threat-dragon-server-bom.json && \
cp td.server/sbom.xml boms/threat-dragon-server-bom.xml && \
cp td.vue/dist/.sbom/bom.json boms/threat-dragon-site-bom.json && \
cp td.vue/dist/.sbom/bom.xml boms/threat-dragon-site-bom.xmlBoth CycloneDX JSON and XML formats are generated for both the server and the Vue.js frontend. The SBOMs end up at /app/boms/ in the final image, available for runtime verification.
CI Pipeline SBOM: Grype
Grype generates SBOMs as CI artifacts using Anchore’s sbom-action:
# Grype - release.yaml
- uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
continue-on-error: true
with:
artifact-name: sbom.spdx.jsonThe SBOM is uploaded as a GitHub Actions artifact and attached to the release. This uses SPDX format.
OCI-Attached SBOM: dep-scan
dep-scan takes the most sophisticated approach — generating SBOMs for both the Python project and the Docker image, then attaching them directly to the container registry:
# dep-scan - pythonpublish.yml
- name: Generate SBOM with cdxgen
run: |
npm install -g @cyclonedx/cdxgen
cdxgen -t python -o bom.json . --profile research
cdxgen -t docker -o depscan-oci-image.cdx.json ghcr.io/owasp-dep-scan/depscan:latest
- name: Attach sbom to the image
if: startsWith(github.ref, 'refs/tags/')
continue-on-error: true
run: |
oras attach --artifact-type sbom/cyclonedx \
ghcr.io/owasp-dep-scan/depscan:latest \
./depscan-oci-image.cdx.json:application/json
oras discover --format tree ghcr.io/owasp-dep-scan/depscan:latestoras attach uses OCI referrers to attach the SBOM to the image manifest in the registry. This means anyone pulling the image can also retrieve its SBOM. The oras discover --format tree command verifies the attachment.
Monorepo SBOM: Insomnia
Insomnia uses Kong’s shared security action for monorepo-wide SBOM:
# Insomnia - release-build.yml
- id: sca-project
uses: Kong/public-shared-actions/security-actions/sca@a18abf762d6e2444bcbfd20de70451ea1e3bc1b1 # v4.1.1
with:
dir: .
upload-sbom-release-assets: falseBuild-Tool SBOM: minimal (apko)
apko generates SPDX SBOMs natively during image assembly — no separate tool installation or CI step required:
# apko produces sbom-*.spdx.json automatically during build
# The build workflow extracts version information from the SBOM:
- name: Extract version from SBOM
id: version
run: |
PRIMARY_PKG=$(grep -oE '${{ matrix.primary_grep }}[a-z0-9.-]*' ${{ matrix.apko_config }} | head -1)
VERSION=$(jq -r --arg pkg "$PRIMARY_PKG" '.packages[] | select(.name == $pkg) | .versionInfo' sbom-x86_64.spdx.json | head -1)
echo "tag=$VERSION" >> $GITHUB_OUTPUTThe SBOM is generated as a side effect of the apko build command. The workflow then parses it to extract the primary package version for image tagging — the SBOM is the single source of truth for what went into the image.
SBOM Strategy Recommendations
- For published images: Use OCI referrers (dep-scan’s oras approach) so SBOMs travel with the image
- For release artifacts: Upload as GitHub release assets (Grype’s approach)
- For compliance: Bake into the image (Threat Dragon’s approach) for offline verification
- Format: CycloneDX for security tooling, SPDX for license compliance
Vulnerability Scanning in CI
PR-Gated Scanning: Threat Dragon
Threat Dragon makes Trivy scanning a hard gate on every pull request:
# Threat Dragon - pull_request.yaml
build_docker_image:
name: Build docker image
runs-on: ubuntu-24.04
needs: e2e_smokes
steps:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
with:
install: true
- name: Cache Docker layers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile') }}
- name: Build for amd64
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
outputs: type=docker,dest=/tmp/${{ env.IMAGE_NAME }}.tar
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Upload docker local image
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.IMAGE_NAME }}
path: /tmp/${{ env.IMAGE_NAME }}.tar
scan_image_with_trivy:
name: Scan with Trivy
runs-on: ubuntu-24.04
needs: build_docker_image
permissions:
contents: read
steps:
- name: Retrieve local docker image
uses: actions/download-artifact@3e5f45b2cfb1
7a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ env.IMAGE_NAME }}
path: /tmp
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: '${{ env.IMAGE_NAME }}'
format: 'table'
trivyignores: '.github/workflows/.trivyignore'
exit-code: 1
skip-files: '/app/docs/configure/bitbucket.html,/app/docs/assets/search.json'The pattern is: build → save image as artifact → scan in separate job. exit-code: 1 makes Trivy block the PR on any finding. skip-files excludes known false positives (generated documentation files).
Release-Gated Scanning with Severity Filter: oCIS
oCIS adds severity filtering and a manual block step to their release pipeline:
# oCIS - release.yml
docker-scan:
name: docker-scan (${{ matrix.arch }}, ${{ matrix.repo }})
runs-on: ubuntu-latest
needs: [determine-release-type, docker-build]
strategy:
matrix:
arch: [amd64]
repo: ${{ fromJSON(needs.determine-release-type.outputs.docker_repos) }}
steps:
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
id: trivy
continue-on-error: true
with:
image-ref: ${{ matrix.repo }}:${{ needs.determine-release-type.outputs.version }}-linux-${{ matrix.arch }}
format: table
exit-code: 1
severity: HIGH,CRITICAL
ignore-unfixed: true
skip-files: /usr/bin/gomplate,/usr/bin/wait-for
hide-progress: true
env:
TRIVY_IGNOREFILE: .trivyignore
- name: Block on vulnerabilities
if: steps.trivy.outcome == 'failure'
run: |
echo "::error title=Security scan blocked release::Image ${{ matrix.repo }}:${{ needs.determine-release-type.outputs.version }}-linux-${{ matrix.arch }} has HIGH or CRITICAL vulnerabilities (see Trivy report above). Fix all findings before releasing."
exit 1Key design choices:
severity: HIGH,CRITICAL— only blocks on serious findings, LOW/MEDIUM are informationalignore-unfixed: true— skips CVEs that have no available patch (avoids blocking on vendor responsibility)continue-on-error: true+ manual block — captures the scan result without immediately failing, then provides a clear error message in the block stepTRIVY_IGNOREFILE: .trivyignore— project-specific exception list
oCIS also runs filesystem scans on source code:
security-scan-trivy:
runs-on: ubuntu-latest
steps:
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
with:
scan-type: fs
format: table
exit-code: 0
severity: CRITICAL,HIGHThis catches vulnerabilities in lockfiles and dependencies before they even reach the Docker build.
Image Signing and Provenance
Image signing verifies that an image was built by a trusted source and hasn’t been tampered with. Our projects use three different approaches:
Cosign (Sigstore): OpenTofu and Grype
OpenTofu uses cosign as part of its GoReleaser release pipeline:
# OpenTofu - release.yml
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: v2.2.0Grype installs cosign in validation workflows to verify signed artifacts:
# Grype - validations.yaml
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 #v4.1.1Cosign supports keyless signing via Sigstore’s transparency log, meaning you don’t need to manage private keys — the signing is tied to the CI identity (GitHub OIDC token).
Chalk (CrashAppSec): ZAP
ZAP uses Chalk for image signing and provenance:
# ZAP - release-live-docker.yml
- name: Set up Chalk
uses: crashappsec/setup-chalk-action@main
with:
password: ${{ secrets.CHALK_PASSWORD }}
public_key: ${{ secrets.CHALK_PUBLIC_KEY }}
private_key: ${{ secrets.CHALK_PRIVATE_KEY }}Chalk embeds metadata (build time, commit, signer) directly into the image and uses asymmetric key pairs for verification.
DigiCert Software Trust: Insomnia
Insomnia uses enterprise-grade code signing via DigiCert for Windows binaries:
# Insomnia - release-build.yml
- name: Setup Software Trust Manager
if: runner.os == 'Windows'
uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1.0.0
with:
simple-signing-mode: true
env:
SM_HOST: ${{ vars.DIGICERT_SM_HOST }}
SM_API_KEY: ${{ secrets.DIGICERT_SM_API_KEY }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.DIGICERT_SM_CLIENT_CERT_PASSWORD }}Keyless Cosign Signing: minimal
rtvkiz/minimal signs every published image with cosign using GitHub’s OIDC identity — no stored keys, no secret management:
# minimal - build.yml (both build-apko and build-melange jobs)
- name: Setup cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Sign image with cosign
if: github.event_name != 'pull_request'
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
with:
timeout_minutes: 5
max_attempts: 3
command: |
cosign sign --yes ${{ env.REGISTRY }}/${{ github.repository_owner }}/minimal-${{ matrix.name }}:${{ steps.version.outputs.tag }}Keyless signing (--yes with no --key) uses the GitHub Actions OIDC token to sign via Sigstore’s Fulcio CA. The signature is recorded in the Rekor transparency log, making it publicly auditable. The nick-fields/retry wrapper handles transient registry timeouts — signing 41 images per build means occasional flakes.
For melange-built images, package signing uses an asymmetric key pair:
# melange-build job
- name: Generate ephemeral signing key
if: github.event_name == 'pull_request'
run: melange keygen
- name: Setup signing key (protected branch)
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.MELANGE_SIGNING_KEY }}" > melange.rsa
echo "${{ secrets.MELANGE_SIGNING_KEY_PUB }}" > melange.rsa.pubPRs use ephemeral keys (generated per-run); the main branch uses stored secrets. This ensures PR builds are reproducible without exposing production signing keys.
Signing Approach Selection
- Open source / CI-native: Cosign with keyless signing (free, transparent, Sigstore-backed)
- Need embedded metadata: Chalk (build provenance baked into image)
- Enterprise compliance: DigiCert / Venafi (PKI-backed, audit trail)
Multi-Architecture Builds
Multi-arch images ensure your container works across different CPU architectures. The approaches range from simple to industrial.
Makefile-Driven Buildx: Traefik
Traefik uses a Makefile target that pre-builds binaries then uses buildx for multi-arch:
# Traefik - Makefile
DOCKER_BUILD_PLATFORMS ?= linux/amd64,linux/arm64
multi-arch-image-%: binary-linux-amd64 binary-linux-arm64
docker buildx build $(DOCKER_BUILDX_ARGS) \
-t traefik/traefik:$* \
--platform=$(DOCKER_BUILD_PLATFORMS) \
-f Dockerfile .CI workflow:
# Traefik - experimental.yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build docker experimental image
env:
DOCKER_BUILDX_ARGS: "--push"
run: make multi-arch-image-experimental-${GITHUB_REF##*/}GoReleaser Multi-Arch with Manifests: Grype
Grype has the most comprehensive multi-arch setup — 4 architectures × 3 image variants:
# Grype - .goreleaser.yaml
dockers:
# Production images - 4 architectures
- image_templates:
- anchore/grype:{{.Tag}}-amd64
- ghcr.io/anchore/grype:{{.Tag}}-amd64
goarch: amd64
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
- "--provenance=false"
- image_templates:
- anchore/grype:{{.Tag}}-arm64v8
- ghcr.io/anchore/grype:{{.Tag}}-arm64v8
goarch: arm64
...
- image_templates:
- anchore/grype:{{.Tag}}-ppc64le
goarch: ppc64le
...
- image_templates:
- anchore/grype:{{.Tag}}-s390x
goarch: s390x
...
# Nonroot variants - same 4 architectures
- image_templates:
- anchore/grype:{{.Tag}}-nonroot-amd64
dockerfile: Dockerfile.nonroot
...
docker_manifests:
- name_template: anchore/grype:latest
image_templates:
- anchore/grype:{{.Tag}}-amd64
- anchore/grype:{{.Tag}}-arm64v8
- anchore/grype:{{.Tag}}-ppc64le
- anchore/grype:{{.Tag}}-s390x
- name_template: ghcr.io/anchore/grype:nonroot
image_templates:
- ghcr.io/anchore/grype:{{.Tag}}-nonroot-amd64
- ghcr.io/anchore/grype:{{.Tag}}-nonroot-arm64v8
- ghcr.io/anchore/grype:{{.Tag}}-nonroot-ppc64le
- ghcr.io/anchore/grype:{{.Tag}}-nonroot-s390xThe docker_manifests section creates multi-arch manifests so docker pull anchore/grype:latest automatically selects the right architecture.
OpenTofu’s Dual Dockerfile Approach
OpenTofu offers two Dockerfiles per architecture:
# OpenTofu - .goreleaser.yaml
dockers:
- use: buildx
goarch: amd64
image_templates:
- "ghcr.io/opentofu/opentofu:{{ .Version }}-amd64"
- use: buildx
goarch: amd64
dockerfile: Dockerfile.minimal
image_templates:
- "ghcr.io/opentofu/opentofu:{{ .Version }}-minimal-amd64"
# Same pattern for arm64, arm, 386...
docker_manifests:
- name_template: ghcr.io/opentofu/opentofu:{{ .Version }}
image_templates:
- ghcr.io/opentofu/opentofu:{{ .Version }}-amd64
- ghcr.io/opentofu/opentofu:{{ .Version }}-arm64
- ghcr.io/opentofu/opentofu:{{ .Version }}-arm
- ghcr.io/opentofu/opentofu:{{ .Version }}-386
- name_template: ghcr.io/opentofu/opentofu:{{ .Version }}-minimal
image_templates:
- ghcr.io/opentofu/opentofu:{{ .Version }}-minimal-amd64
- ghcr.io/opentofu/opentofu:{{ .Version }}-minimal-arm64
- ghcr.io/opentofu/opentofu:{{ .Version }}-minimal-arm
- ghcr.io/opentofu/opentofu:{{ .Version }}-minimal-3864 architectures × 2 Dockerfile variants = 8 per-arch images, combined into 2 multi-arch manifests.
Simple Build-Push: Gitleaks
The simplest multi-arch approach:
# Gitleaks - release.yml
- name: Set up QEMU
uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6
- name: Build and push Docker image
uses: docker/build-push-action@e551b19e49efd4e98792db7592c17c09b89db8d8
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}No manifest management needed — build-push-action handles it automatically when you specify multiple platforms.
Nydus for Faster Pulls: dep-scan
dep-scan goes beyond multi-arch by converting images to Nydus format for faster container pulls:
# dep-scan - pythonpublish.yml
- name: Setup nydus
run: |
NYDUS_ARCHIVE="nydus-static-${NYDUS_VERSION}-linux-amd64.tgz"
curl -LO "https://github.com/dragonflyoss/nydus/releases/download/${NYDUS_VERSION}/${NYDUS_ARCHIVE}"
echo "${NYDUS_STATIC_AMD64_SHA256} ${NYDUS_ARCHIVE}" | sha256sum -c -
tar -xvf "${NYDUS_ARCHIVE}"
chmod +x nydus-static/*
mv nydus-static/* /usr/local/bin/
- name: nydusify
run: |
nydusify convert --oci --oci-ref \
--source ghcr.io/owasp-dep-scan/depscan:master \
--target ghcr.io/owasp-dep-scan/depscan:master-nydus \
--prefetch-dir /opt/dep-scan
nydusify check --target ghcr.io/owasp-dep-scan/depscan:master-nydusNydus uses a chunk-based image format that enables on-demand loading — container startup without downloading the entire image. The --prefetch-dir flag pre-loads critical files for fast startup. SHA256 checksum verification ensures the Nydus binary itself is trusted.
.dockerignore Best Practices
A good .dockerignore prevents sensitive files and unnecessary build context from reaching the Docker daemon.
Comprehensive Exclusion: Threat Dragon
*/coverage
dev-notes.md
dist/
*/dist/
.env
example.env
.git*
docs/.jekyll-cache
log/
*/node_modules
node_modules/
nyc_output/
*/nyc_output/
ThreatDragonModels/
utils/
.vscode/
*/.vscode/
*/dist-desktop/
sbom.*
*/sbom.*
# Generated files, not relevant in CI but in local development,
# these files will break the docker build if included
td.vue/src/service/schema/api_json/
Notable: excludes .env files, .git*, IDE configs, coverage output, and generated schema files that would break the build.
Selective Negation: Traefik
dist/
!dist/**/traefik
site/
vendor/
.idea/
The !dist/**/traefik line is key — it excludes the entire dist/ directory but specifically keeps the compiled traefik binary. This prevents accidentally including intermediate build artifacts while keeping what’s needed.
Minimal but Effective: Checkov
bin/
checkov/
docs/
integration_tests/
tests/
Just 5 lines — excludes compiled binaries, the package itself (installed via pip), documentation, and tests. Simple and effective.
Health Checks
HEALTHCHECK instructions let the container runtime (Docker, Kubernetes) know if the application is actually working, not just running.
Node.js Application Check: Threat Dragon
HEALTHCHECK --interval=10s --timeout=2s --start-period=2s \
CMD ["/nodejs/bin/node", "./td.server/dist/healthcheck.js"]Uses a dedicated healthcheck script rather than a curl request — cleaner for Node.js applications and doesn’t require curl in the image.
HTTP Health Check: ZAP
ENV ZAP_PORT=8080
HEALTHCHECK CMD curl --silent --output /dev/null --fail http://localhost:$ZAP_PORT/ || exit 1Standard HTTP health check. --fail makes curl return non-zero on HTTP errors.
High-Tolerance Startup: Wazuh
HEALTHCHECK --interval=5s --timeout=30s --start-period=5s --retries=35 \
CMD /scripts/healthcheck.sh35 retries × 5s intervals = 175 seconds of tolerance. Wazuh manager can take several minutes to fully initialize, so the high retry count prevents false-negative health checks during startup.
Missing Health Checks
Layer Caching and Build Optimization
Buildx Cache with Workaround: Threat Dragon
# Threat Dragon - pull_request.yaml
- name: Cache Docker layers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile') }}
restore-keys: |
${{ runner.os }}-buildx-
${{ runner.os }}-
- name: Build for amd64
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
# Workaround for cache growing indefinitely
# https://github.com/docker/build-push-action/issues/252
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cacheThe cache key includes the Dockerfile hash, so cache is invalidated when the Dockerfile changes. The mode=max flag stores all intermediate layers, not just the final ones. The move workaround prevents the cache from growing unboundedly — a known issue with buildx local cache.
Vulnerability Exception Management
The Gold Standard: oCIS
oCIS’s .trivyignore is the best example of vulnerability exception management in our analysis:
# Trivy vulnerability ignore file
# Add CVE IDs or file paths here to suppress known/accepted findings.
# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/#trivyignore
# Alpine 3.23.3 ships vulnerable package versions; no fixed base image exists yet.
# Fix: bump FROM alpine:3.23.3 → alpine:3.23.4 once released, or add
# RUN apk upgrade --no-cache
# to Dockerfile.linux.amd64 and Dockerfile.linux.arm64.
CVE-2026-28390 # libcrypto3/libssl3 3.5.5-r0 → fixed in 3.5.6-r0 (openssl DoS)
CVE-2026-22184 # zlib 1.3.1-r2 → fixed in 1.3.2-r0 (buffer overflow in untgz)
CVE-2026-40200 # musl/musl-utils 1.2.5-r21 → fixed in 1.2.5-r23 (stack-based arbitrary code execution / DoS)
Every exception includes:
- Context — why the vulnerability exists (Alpine version)
- Remediation path — exactly what to change (bump FROM line)
- Fix version — the specific version that resolves the issue
- Impact — what the vulnerability actually is
Good With Room to Improve: Threat Dragon
# https://avd.aquasec.com/nvd/cve-2023-28155
# request version prior to 2.88.2
# this vulnerability is for the build system, not run time, so ignore
CVE-2023-28155
# ignore until Vue2 to Vue3 upgrade
CVE-2025-15284
# uuid is only used at runtime via v4; this issue affects v3/v5/v6 with caller buffers.
GHSA-w5hq-g745-h8pq
Good: links to the vulnerability, explains why it’s safe to ignore. Missing: no fix version or expiry plan.
.trivyignore Best Practices
Every exception should answer four questions:
- What is the vulnerability? (link to CVE/GHSA)
- Why is it safe to ignore? (build-only, no fix available, not applicable)
- When will it be fixed? (next base image bump, upstream patch)
- Who is responsible? (implicit: the team that added the exception)
Continuous CVE Remediation: Rebuild Cadence and Automation
Most projects scan for vulnerabilities and file tickets. minimal eliminates them by rebuilding from patched sources every 6 hours.
Scheduled Rebuilds
The build workflow triggers on four events, with schedule as the primary CVE remediation mechanism:
# minimal - build.yml
on:
push:
branches: [main]
paths:
- '*/apko/*.yaml'
- '*/melange.yaml'
- '*/test.sh'
- '.github/workflows/build.yml'
pull_request:
branches: [main]
schedule:
# Rebuild every 6 hours to keep images and vulnerability counts fresh
- cron: '17 */6 * * *'
workflow_dispatch:On scheduled builds, every image is rebuilt regardless of changes. This means:
- Wolfi packages get patched within 24-48 hours of upstream CVE disclosure
- Source-built images (via melange) recompile from the same versioned tarball but with updated build dependencies
- Grype scans run on every rebuild, and results are published to a GitHub Pages vulnerability dashboard
The concurrency configuration prevents wasted runs:
concurrency:
group: build-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.run_id || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}Change Detection for Push Builds
For push-triggered builds, the workflow detects which images changed and only rebuilds those:
EVENT="${{ github.event_name }}"
FULL_REBUILD=false
if [[ "$EVENT" == "schedule" ]] || [[ "$EVENT" == "workflow_dispatch" ]]; then
FULL_REBUILD=true
else
CHANGED_FILES=$(git diff --name-only "$DIFF_BASE" "${{ github.sha }}" 2>/dev/null)
if echo "$CHANGED_FILES" | grep -q "^\.github/workflows/build\.yml$"; then
FULL_REBUILD=true
fi
fi
if [ "$FULL_REBUILD" = true ]; then
CHANGED=$(echo "$MELANGE_IMAGES $APKO_IMAGES" | jq -s 'add | [.[].name]')
else
CHANGED=$(echo "$CHANGED_FILES" | grep -E '^[^./][^/]*/' | sed 's|/.*||' | sort -u | jq -R . | jq -s .)
fiAutomated Version Update Pipelines
rtvkiz/minimal has 30 dedicated update workflows — one per source-built image. Each runs daily, checks for new upstream versions, and opens a PR:
# .github/workflows/update-redis.yml
name: Update Redis Version
on:
schedule:
- cron: '30 6 * * *'
workflow_dispatch:
jobs:
check-update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Generate app token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Check for new Redis stable version
id: check
run: |
LATEST=$(curl -sS --retry 3 "https://download.redis.io/releases/" | grep -oE 'redis-[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
CURRENT=$(grep '^ version:' redis-slim/melange.yaml | awk '{print $2}')
if [ "$LATEST" != "$CURRENT" ]; then
echo "update_available=true" >> $GITHUB_OUTPUT
echo "new_version=$LATEST" >> $GITHUB_OUTPUT
echo "current_version=$CURRENT" >> $GITHUB_OUTPUT
fi
- name: Update version and checksum
if: steps.check.outputs.update_available == 'true'
run: |
VERSION="${{ steps.check.outputs.new_version }}"
curl -fsSL "https://github.com/redis/redis/archive/refs/tags/${VERSION}.tar.gz" -o /tmp/redis.tar.gz
SHA256=$(sha256sum /tmp/redis.tar.gz | awk '{print $1}')
sed -i "s/^ version: .*/ version: $VERSION/" redis-slim/melange.yaml
sed -i "s/^ sha256: .*/ sha256: $SHA256/" redis-slim/melange.yaml
sed -i "s/^ epoch: .*/ epoch: 0/" redis-slim/melange.yaml
- name: Create PR and enable auto-merge
if: steps.check.outputs.update_available == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
BRANCH="update-redis-${{ steps.check.outputs.new_version }}"
git checkout -b "$BRANCH"
git add -A
git commit -m "chore(redis): bump to ${{ steps.check.outputs.new_version }}"
git push --force -u origin "$BRANCH"
PR_URL=$(gh pr create --title "chore(redis): bump to ${{ steps.check.outputs.new_version }}" --label dependencies --label redis --body "Updates Redis to ${{ steps.check.outputs.new_version }}")
gh pr merge "$PR_URL" --auto --squash --delete-branchKey elements of the update pattern:
- GitHub App token — PRs created by the app token don’t trigger nested workflows, avoiding infinite loops
- SHA256 verification — The checksum is updated alongside the version, so melange verifies tarball integrity at build time
- Epoch reset —
epoch: 0for new upstream versions; increment epoch only for in-house patches - Auto-merge —
gh pr merge --auto --squashqueues the PR for merge once CI passes
Published Vulnerability Dashboard
Every scheduled rebuild publishes an HTML vulnerability report to GitHub Pages:
# minimal - build.yml (publish-vuln-report job)
- name: Generate HTML report
run: |
for f in reports/grype-*.json; do
NAME=$(basename "$f" .json | sed 's/^grype-//')
C=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$f")
H=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "$f")
# Build styled HTML table rows...
doneThis gives a public, continuously-updated view of vulnerability counts across all 41 images.
Rebuild vs. Scan-and-Ticket
- Rebuild approach (rtvkiz/minimal): Patch the source and rebuild. CVEs disappear when the package is updated. Requires daily rebuild infrastructure.
- Scan-and-ticket approach (most projects): Scan the image, file issues for CVEs. Faster to implement but vulnerabilities persist until someone acts.
- Hybrid: Rebuild on schedule (catches dependency CVEs) + scan on push (catches code CVEs). This is what rtvkiz/minimal actually does.
Putting It All Together — Reference Dockerfile
Synthesizing the best patterns from all 13 codebases into a single annotated reference Dockerfile for a Node.js application:
# === VERSION PINNING (Threat Dragon pattern) ===
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21
# === STAGE 1: Hardened base ===
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS base
# Apply security patches (Threat Dragon, Checkov pattern)
RUN apk -U upgrade && \
apk add --no-cache dumb-init
# Create non-root user (oCIS pattern - explicit UID)
RUN addgroup -g 1001 -S appgroup && \
adduser -S --ingroup appgroup --uid 1001 appuser
WORKDIR /app
RUN chown -R appuser:appgroup /app
USER 1001
# === STAGE 2: Build with dev dependencies ===
FROM base AS build
# Copy lockfile first for layer caching
COPY --chown=1001:1001 package-lock.json package.json ./
RUN npm clean-install
# Copy source and build
COPY --chown=1001:1001 . .
RUN npm run build
# Generate SBOM (Threat Dragon pattern)
RUN npm run make-sbom 2>/dev/null || \
npx @cyclonedx/cyclonedx-npm --output-format json > sbom.json
# === STAGE 3: Production image ===
FROM base
# OCI labels (oCIS/Grype pattern)
LABEL org.opencontainers.image.title="my-app" \
org.opencontainers.image.vendor="MyOrg" \
org.opencontainers.image.description="My application" \
org.opencontainers.image.source="https://github.com/myorg/my-app" \
org.opencontainers.image.licenses="MIT"
# Copy SBOMs from build stage (Threat Dragon pattern)
COPY --chown=1001:1001 --from=build /app/sbom.json /app/sbom.json
# Install ONLY production dependencies
COPY --chown=1001:1001 package-lock.json package.json ./
RUN npm clean-install --omit dev --ignore-scripts
# Copy built artifacts only
COPY --chown=1001:1001 --from=build /app/dist ./dist
# Make runtime read-only (dep-scan pattern)
RUN chmod a-w -R /app/node_modules
# Health check (Threat Dragon + ZAP patterns)
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1))" \
|| exit 1
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]This reference incorporates patterns from 6 different projects:
- Version pinning (Threat Dragon)
- System updates (Threat Dragon, Checkov)
- Explicit non-root user with UID (oCIS)
- Multi-stage build (Threat Dragon)
- SBOM in build stage (Threat Dragon)
- OCI labels (oCIS, Grype)
- Read-only runtime (dep-scan)
- Production-only deps (Threat Dragon)
- Health check (Threat Dragon, ZAP)
- dumb-init for PID 1 (best practice for Node.js)
Anti-Patterns to Avoid
Drawn from patterns observed (or notably absent) across the 13 codebases:
1. Running as root OpenTofu, Traefik, Checkov, and Gitleaks all run as root in their standard Dockerfiles. For CLI tools executed ephemerally this is acceptable; for long-running services it’s a critical gap.
2. No HEALTHCHECK Only Threat Dragon, ZAP, and Wazuh include HEALTHCHECK directives. For any long-running service (Traefik, oCIS), this is a significant omission.
3. apt-get upgrade without pinning
Checkov and Threat Dragon run apk -U upgrade / apt-get -y upgrade which makes builds non-reproducible — different runs may get different package versions. Prefer pinning specific versions.
4. Missing .dockerignore Wazuh has no .dockerignore file, meaning the entire repo context (including test data, scripts, configs) is sent to the Docker daemon on every build.
5. COPY . . instead of specific files
Several Dockerfiles copy the entire context first, then build. The Threat Dragon pattern of copying lockfiles first, then source, enables Docker layer caching to skip dependency installation when only source files change.
6. Missing OCI labels oCIS and Grype are exemplary with OCI labels. Most other projects omit them, losing build provenance metadata.
7. No vulnerability scanning in CI Only Threat Dragon and oCIS run container image scans as CI gates. Most projects scan source code but not the actual images they ship.
8. Secrets in environment variables
Avoid ENV DB_PASSWORD=... in Dockerfiles. Use Docker secrets (--mount=type=secret) as ZAP does, or runtime secret injection.
9. FROM node:latest or unpinned tags
Every project in our analysis pins base image versions. Using :latest or untagged images makes builds unreproducible and can introduce unexpected vulnerabilities.
10. Single-stage builds for complex apps Checkov and dep-scan use single-stage builds despite having complex build requirements. Multi-stage builds are almost always worth the added Dockerfile complexity.
Related
- best-cicd-implementations-reference-guide — CI/CD pipeline patterns from the same codebases
- analyzing-minimal-container-images — Deep dive on rtvkiz/minimal: 41 hardened images, apko/melange builds, daily CVE rebuilds, cosign signing
- analyzing-trivy — Trivy vulnerability scanner analysis
- analyzing-grype — Grype vulnerability scanner with SBOM-based matching
- analyzing-threat-dragon — 4-stage Dockerfile, DAST with ZAP, Trivy scanning
- analyzing-ocis — Non-root containers, .trivyignore management, Drone+GHA dual CI