tutorial

Monitoring Hadolint (Dockerfile Linter) in CI/CD with Vigilmon

Hadolint is the standard Dockerfile linter — but when it silently disappears from your CI pipeline, gets pinned to a stale version with missing rules, or is bypassed by misconfigured ignore lists, Dockerfile quality regressions ship undetected. Here's how to monitor Hadolint CI gate health, lint rule coverage, version currency, and pipeline bypass rates with Vigilmon.

Hadolint is a Dockerfile linter built on top of a Dockerfile parser combined with a shell script analyzer (ShellCheck) for RUN instructions. It enforces Dockerfile best practices from the official Docker documentation and community conventions: pinning base image digests, avoiding apt-get upgrade, using COPY instead of ADD, and dozens of other rules across the DL and SC rule families. In most organizations, Hadolint runs as a mandatory CI gate that blocks merges when Dockerfiles violate best practices. When Hadolint disappears from the pipeline — because the binary download fails, the CI image is updated without it, or a step is accidentally commented out — Dockerfiles with security vulnerabilities, bloated layers, and non-reproducible builds merge silently. When teams add broad HADOLINT_IGNORE overrides to unblock blocked PRs, the lint gate becomes theater. Vigilmon gives you external monitoring for Hadolint CI gate execution, lint rule enforcement coverage, Hadolint version currency, and pipeline bypass rates so you know when your Dockerfile quality gate is compromised.

What You'll Set Up

  • Hadolint CI gate execution health via cron heartbeats
  • Lint rule coverage and ignore-list drift monitoring
  • Hadolint binary version currency checks
  • CI pipeline bypass rate tracking
  • Scheduled Dockerfile quality audits

Prerequisites

  • Hadolint installed in your CI environment (GitHub Actions, GitLab CI, Jenkins, etc.)
  • Access to CI job logs or artifact outputs
  • A .hadolint.yaml configuration file in your repository
  • A free Vigilmon account

Step 1: Monitor Hadolint CI Gate Execution

The most critical failure mode is Hadolint silently disappearing from the CI pipeline. This happens when a CI image is updated and hadolint is no longer installed, when a CI step fails to download the Hadolint binary and the step is marked as non-fatal (continue-on-error: true), or when a pipeline refactor accidentally removes the lint step. Monitor that Hadolint actually runs on every Dockerfile-affecting commit:

Create a wrapper script that pings Vigilmon after a successful Hadolint execution:

#!/bin/bash
# scripts/hadolint-gate.sh — replace direct hadolint invocations in CI with this

HEARTBEAT_URL="${HADOLINT_HEARTBEAT_URL:-https://vigilmon.online/heartbeat/abc123}"
HADOLINT_CONFIG="${HADOLINT_CONFIG:-.hadolint.yaml}"
DOCKERFILE="${1:-Dockerfile}"
EXIT_ON_WARN="${HADOLINT_EXIT_ON_WARN:-false}"

# Verify hadolint is actually installed
if ! command -v hadolint &>/dev/null; then
  echo "ERROR: hadolint not found in PATH — Dockerfile linting skipped!"
  echo "Install: https://github.com/hadolint/hadolint/releases"
  exit 1  # Fail the CI step — never silently skip linting
fi

# Print hadolint version for audit trail
HADOLINT_VERSION=$(hadolint --version 2>&1 | grep -oP 'Haskell Dockerfile Linter \K[\d.]+' || echo "unknown")
echo "Running hadolint ${HADOLINT_VERSION} on ${DOCKERFILE}"

# Run hadolint with the project configuration
hadolint \
  --config "$HADOLINT_CONFIG" \
  --format json \
  "$DOCKERFILE" > /tmp/hadolint-results.json 2>&1
HADOLINT_EXIT=$?

# Count findings by severity
ERRORS=$(python3 -c "
import json, sys
try:
    results = json.load(open('/tmp/hadolint-results.json'))
    print(sum(1 for r in results if r.get('level') == 'error'))
except: print(0)
" 2>/dev/null || echo 0)

WARNINGS=$(python3 -c "
import json, sys
try:
    results = json.load(open('/tmp/hadolint-results.json'))
    print(sum(1 for r in results if r.get('level') == 'warning'))
except: print(0)
" 2>/dev/null || echo 0)

# Print results in human-readable format too
hadolint --config "$HADOLINT_CONFIG" "$DOCKERFILE" || true

echo "Hadolint summary: ${ERRORS} errors, ${WARNINGS} warnings"

# Ping heartbeat to confirm the gate ran
curl -s "$HEARTBEAT_URL"

# Exit with hadolint's exit code to block CI on errors
exit "$HADOLINT_EXIT"
  1. In Vigilmon, click Add MonitorCron Heartbeat.
  2. Set Expected ping interval to match your typical PR frequency (e.g., 1 hour for active repos, 8 hours for quieter ones).
  3. Copy the heartbeat URL into HADOLINT_HEARTBEAT_URL as a CI environment variable.

If the heartbeat stops arriving, Vigilmon alerts — indicating that either no Dockerfiles were changed (acceptable) or the lint gate is broken (critical). Cross-reference with your VCS activity to distinguish the two cases.


Step 2: Monitor Ignore-List Drift

Hadolint's ignore list in .hadolint.yaml is a common escape hatch when the lint gate blocks a PR that the team wants to merge quickly. Over time, the ignore list accumulates rules that were added to unblock a single PR and never removed, effectively hollowing out the lint gate. Monitor ignore-list growth to catch rule coverage erosion:

#!/bin/bash
# /usr/local/bin/hadolint-ignore-check.sh

HEARTBEAT_URL="https://vigilmon.online/heartbeat/def456"
REPO_DIR="/opt/repos/my-repo"
HADOLINT_CONFIG="${REPO_DIR}/.hadolint.yaml"
MAX_IGNORE_RULES=10   # Alert if more than 10 rules are ignored
MAX_INLINE_IGNORES=5  # Alert if more than 5 hadolint:ignore inline comments

if [ ! -f "$HADOLINT_CONFIG" ]; then
  echo "No .hadolint.yaml found — ignore rules not configured"
  curl -s "$HEARTBEAT_URL"
  exit 0
fi

# Count rules in the ignore list
IGNORE_COUNT=$(python3 -c "
import yaml
try:
    with open('${HADOLINT_CONFIG}') as f:
        config = yaml.safe_load(f)
    ignores = config.get('ignore', [])
    print(len(ignores))
except:
    print(0)
" 2>/dev/null || \
  grep -c "^\s*- DL\|^\s*- SC" "$HADOLINT_CONFIG" 2>/dev/null || echo 0)

# Count inline hadolint:ignore comments in all Dockerfiles
INLINE_IGNORES=$(find "$REPO_DIR" -name "Dockerfile*" -not -path "*/vendor/*" \
  -exec grep -c "# hadolint ignore\|# noqa" {} \; 2>/dev/null | \
  awk '{s+=$1} END {print s+0}')

IGNORE_COUNT="${IGNORE_COUNT:-0}"
INLINE_IGNORES="${INLINE_IGNORES:-0}"
TOTAL_BYPASSES=$(( IGNORE_COUNT + INLINE_IGNORES ))

FAILED=0

if [ "$IGNORE_COUNT" -ge "$MAX_IGNORE_RULES" ]; then
  echo "Hadolint ignore list too long: ${IGNORE_COUNT} rules suppressed (max: ${MAX_IGNORE_RULES})"
  FAILED=1
fi

if [ "$INLINE_IGNORES" -ge "$MAX_INLINE_IGNORES" ]; then
  echo "Too many inline hadolint ignores: ${INLINE_IGNORES} (max: ${MAX_INLINE_IGNORES})"
  FAILED=1
fi

if [ "$FAILED" -eq 0 ]; then
  echo "Ignore list OK: ${IGNORE_COUNT} config ignores, ${INLINE_IGNORES} inline ignores"
  curl -s "$HEARTBEAT_URL"
else
  exit 1
fi

Run this check nightly with a Vigilmon heartbeat at 25 hour expected interval. When the ignore count grows, review which rules were added and whether they represent intentional policy choices (document them in .hadolint.yaml with comments) or quick bypasses that should be fixed and removed.


Step 3: Monitor Hadolint Version Currency

Hadolint releases regularly add new rules, update the embedded ShellCheck version, and fix false positives. Running a stale Hadolint version means your lint gate misses newly-introduced best practice rules (particularly for new Dockerfile syntax) and may produce incorrect findings. Monitor the installed version against the latest GitHub release:

#!/bin/bash
# /usr/local/bin/hadolint-version-check.sh

HEARTBEAT_URL="https://vigilmon.online/heartbeat/ghi789"
MAX_VERSION_AGE_DAYS=90   # Alert if running a version older than 90 days
GITHUB_API_URL="https://api.github.com/repos/hadolint/hadolint/releases/latest"
TIMEOUT=15

# Get currently installed version
if ! command -v hadolint &>/dev/null; then
  echo "hadolint not installed"
  exit 1
fi

CURRENT_VERSION=$(hadolint --version 2>&1 | grep -oP '[\d]+\.[\d]+\.[\d]+' | head -1)

if [ -z "$CURRENT_VERSION" ]; then
  echo "Could not determine hadolint version"
  exit 1
fi

# Get latest release version from GitHub
LATEST_VERSION=$(curl -s \
  --max-time "$TIMEOUT" \
  -H "Accept: application/vnd.github.v3+json" \
  "$GITHUB_API_URL" | \
  python3 -c "
import sys, json
try:
    data = json.load(sys.stdin)
    tag = data.get('tag_name', 'v0.0.0')
    print(tag.lstrip('v'))
except:
    print('0.0.0')
" 2>/dev/null)

LATEST_VERSION="${LATEST_VERSION:-0.0.0}"

# Compare versions (simple string comparison works for semver)
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
  echo "Hadolint up-to-date: ${CURRENT_VERSION}"
  curl -s "$HEARTBEAT_URL"
elif python3 -c "
from packaging.version import Version
import sys
current = '${CURRENT_VERSION}'
latest = '${LATEST_VERSION}'
# Allow up to 2 minor versions behind
curr = Version(current)
lat = Version(latest)
ok = (lat.major == curr.major and lat.minor - curr.minor <= 2)
sys.exit(0 if ok else 1)
" 2>/dev/null; then
  echo "Hadolint slightly behind: ${CURRENT_VERSION} (latest: ${LATEST_VERSION}) — consider updating"
  curl -s "$HEARTBEAT_URL"
else
  echo "Hadolint version stale: ${CURRENT_VERSION} (latest: ${LATEST_VERSION})"
  exit 1
fi

Run this check weekly with a Vigilmon heartbeat at 8 day expected interval. When Hadolint is found to be stale, update the version pinned in your CI Dockerfile or GitHub Actions workflow:

# .github/workflows/lint.yml
- name: Run Hadolint
  uses: hadolint/hadolint-action@v3.1.0
  with:
    hadolint-version: "2.12.0"  # Pin to a specific version
    dockerfile: Dockerfile

Step 4: Track Pipeline Bypass Rates

CI pipelines often include mechanisms that allow bypassing mandatory gates — commit message keywords ([skip ci]), PR labels, or manual pipeline approvals. When these bypass mechanisms are used to skip Hadolint on Dockerfiles, security and quality regressions enter the main branch unreviewed. Track how often Hadolint is skipped:

#!/bin/bash
# /usr/local/bin/hadolint-bypass-check.sh

HEARTBEAT_URL="https://vigilmon.online/heartbeat/jkl012"
REPO_DIR="/opt/repos/my-repo"
DAYS_TO_CHECK=7
MAX_BYPASS_PCT=10   # Alert if more than 10% of Dockerfile commits skip linting
GIT_LOG_LIMIT=100

# Count total commits touching Dockerfiles in the last N days
TOTAL_DOCKERFILE_COMMITS=$(git -C "$REPO_DIR" log \
  --since="${DAYS_TO_CHECK} days ago" \
  --oneline \
  -- "*Dockerfile*" "*.dockerfile" | wc -l)

# Count commits that include skip-ci patterns
SKIP_COMMITS=$(git -C "$REPO_DIR" log \
  --since="${DAYS_TO_CHECK} days ago" \
  --oneline \
  --grep='\[skip ci\]\|\[no-lint\]\|\[bypass\]' \
  -- "*Dockerfile*" "*.dockerfile" | wc -l)

TOTAL_DOCKERFILE_COMMITS="${TOTAL_DOCKERFILE_COMMITS:-0}"
SKIP_COMMITS="${SKIP_COMMITS:-0}"

if [ "$TOTAL_DOCKERFILE_COMMITS" -eq 0 ]; then
  echo "No Dockerfile commits in the last ${DAYS_TO_CHECK} days"
  curl -s "$HEARTBEAT_URL"
  exit 0
fi

BYPASS_PCT=$(( SKIP_COMMITS * 100 / TOTAL_DOCKERFILE_COMMITS ))

if [ "$BYPASS_PCT" -le "$MAX_BYPASS_PCT" ]; then
  echo "Hadolint bypass rate OK: ${BYPASS_PCT}% (${SKIP_COMMITS}/${TOTAL_DOCKERFILE_COMMITS} commits)"
  curl -s "$HEARTBEAT_URL"
else
  echo "Hadolint bypass rate high: ${BYPASS_PCT}% (${SKIP_COMMITS}/${TOTAL_DOCKERFILE_COMMITS} commits)"
  exit 1
fi

Run this check daily with a Vigilmon heartbeat at 25 hour expected interval. A bypass rate above 10% signals that the lint gate is creating friction the team is working around — investigate whether specific rules are generating too many false positives and should be formally suppressed in .hadolint.yaml rather than bypassed per-commit.


Step 5: Run Scheduled Dockerfile Quality Audits

Beyond monitoring the CI gate, schedule periodic full-repository Hadolint audits that catch Dockerfiles modified outside the normal PR flow (direct pushes, hotfixes, manually applied patches) and generate trend reports on Dockerfile quality over time:

#!/bin/bash
# /usr/local/bin/hadolint-full-audit.sh

HEARTBEAT_URL="https://vigilmon.online/heartbeat/mno345"
REPO_DIR="/opt/repos/my-repo"
HADOLINT_CONFIG="${REPO_DIR}/.hadolint.yaml"
AUDIT_REPORT="/var/log/hadolint-audit-$(date +%Y%m%d).json"
MAX_TOTAL_VIOLATIONS=50   # Alert if more than 50 violations across all Dockerfiles

if ! command -v hadolint &>/dev/null; then
  echo "hadolint not installed"
  exit 1
fi

# Find all Dockerfiles in the repo
DOCKERFILES=$(find "$REPO_DIR" \
  -name "Dockerfile*" \
  -not -path "*/vendor/*" \
  -not -path "*/.git/*" \
  -not -path "*/node_modules/*")

TOTAL_VIOLATIONS=0
AUDIT_RESULTS="[]"

for DOCKERFILE in $DOCKERFILES; do
  RESULTS=$(hadolint \
    --config "$HADOLINT_CONFIG" \
    --format json \
    "$DOCKERFILE" 2>/dev/null || echo "[]")

  VIOLATION_COUNT=$(echo "$RESULTS" | python3 -c "
import json, sys
try:
    data = json.load(sys.stdin)
    print(len(data))
except: print(0)
" 2>/dev/null || echo 0)

  TOTAL_VIOLATIONS=$(( TOTAL_VIOLATIONS + VIOLATION_COUNT ))

  if [ "$VIOLATION_COUNT" -gt 0 ]; then
    echo "  ${DOCKERFILE}: ${VIOLATION_COUNT} violations"
  fi
done

echo "Full audit complete: ${TOTAL_VIOLATIONS} total violations across all Dockerfiles"

# Save audit summary
echo "{\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"total_violations\":${TOTAL_VIOLATIONS}}" \
  > "$AUDIT_REPORT"

if [ "$TOTAL_VIOLATIONS" -le "$MAX_TOTAL_VIOLATIONS" ]; then
  echo "Audit OK: ${TOTAL_VIOLATIONS} violations (max: ${MAX_TOTAL_VIOLATIONS})"
  curl -s "$HEARTBEAT_URL"
else
  echo "Audit FAILED: ${TOTAL_VIOLATIONS} violations exceed threshold of ${MAX_TOTAL_VIOLATIONS}"
  exit 1
fi

Set up a cron job to run this audit nightly:

0 2 * * * /usr/local/bin/hadolint-full-audit.sh >> /var/log/hadolint-audit.log 2>&1

Create a Vigilmon cron heartbeat with 25 hour expected interval. Trend the total violation count over time — a rising trend indicates that Dockerfile quality is degrading despite the CI gate, typically because the gate only runs on changed files (not the full repository) and legacy violations accumulate in files that are rarely touched.


Step 6: Set Up Alert Channels

Configure Vigilmon to route Hadolint monitoring alerts to your DevOps and security teams:

  1. Go to Alert Channels in Vigilmon.
  2. Add a Slack webhook to #ci-alerts — a missing Hadolint gate means Dockerfile vulnerabilities are merging undetected.
  3. Add PagerDuty for the CI gate execution monitor — a gate that stops executing is a security regression.
  4. Add email notification for version currency, ignore-list drift, and bypass rate monitors (these need investigation, not immediate response).
  5. Set Consecutive failures before alert to 1 for the gate execution monitor — a single missed execution could indicate a broken pipeline.

Add a maintenance window during CI pipeline refactors:

curl -X POST https://vigilmon.online/api/maintenance \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "monitor_id": "HADOLINT_GATE_MONITOR_ID",
    "duration_minutes": 60,
    "reason": "CI pipeline refactor — Hadolint gate temporarily disabled"
  }'

Summary

| Monitor | Target | What It Catches | |---|---|---| | Cron heartbeat (gate) | Post-lint ping from CI wrapper | Gate removed from pipeline, binary not installed, continue-on-error set | | Cron heartbeat (ignore-list) | .hadolint.yaml ignore count | Rule coverage erosion from accumulated bypasses | | Cron heartbeat (version) | Installed vs latest GitHub release | Stale version missing new rules and ShellCheck updates | | Cron heartbeat (bypasses) | Git log skip-ci patterns on Dockerfiles | PR bypass abuse, team friction with lint gate | | Cron heartbeat (full audit) | All Dockerfiles in repo | Legacy violations, hotfix regressions, files outside PR flow |

Hadolint's value comes entirely from its CI gate being reliably active. A lint gate that silently disappears, gets hollowed out by a growing ignore list, or is routinely bypassed provides no security or quality guarantees — but creates the illusion that Dockerfiles are being reviewed. Vigilmon's external monitoring surfaces the difference between a healthy gate and a compromised one.

Start monitoring for free at vigilmon.online.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →