Catching Mistakes Early: Setting Up Pre-Commit Hooks for Docker

Sep 17, 2026 min read

It’s 4:45 on a Friday. You’re wrapping up a Dockerized microservice, testing a database connection string against a local instance before you push. The commit message is wip: db config. You type it fast, you’re thinking about the weekend, and you don’t notice that the connection string you pasted from your terminal history still has the real password in it — not the placeholder you meant to swap in. git commit. git push. Done. You close the laptop.

Monday morning there’s a message from a teammate: a bot scraping public repos found the string in your history and it’s already being probed. The password gets rotated within the hour. That part is routine. What isn’t routine is the two hours spent afterward confirming nothing used that credential in the meantime, and the slightly awkward conversation about why it happened. Nobody thinks they’re the kind of engineer who commits a secret — until the one Friday they are.

That’s the trap: relying on a developer to “remember” security best practices while their brain is on five other things is not a strategy, it’s a hope. And once a secret lands in git history, it doesn’t matter how fast you delete it in the next commit — it’s still sitting in every clone, every fork, every CI cache that pulled the repo before the fix. By the time the pipeline catches it, the damage is already done. The only fix that actually works is one that runs before git commit finishes, on your machine, before the secret ever leaves your disk.

That’s what the pre-commit framework is for, and pairing it with a secret scanner and a Dockerfile linter turns “I hope nobody makes a mistake” into “the tool won’t let the mistake happen.”

What Git Hooks Actually Are

Git has shipped with hooks since before most of us were writing code professionally. They’re just scripts that Git runs automatically at specific points in its workflow — before a commit, before a push, after a merge. The pre-commit hook specifically fires after you stage your changes and run git commit, but before the commit object actually gets created. If the hook script exits with a non-zero status, Git aborts the commit. Nothing gets written to history.

The problem with raw Git hooks is that they live in .git/hooks/, which isn’t version-controlled, isn’t shared automatically when someone clones the repo, and usually ends up as a pile of shell scripts nobody wants to maintain. That’s the gap the pre-commit framework — the Python tool, not just the Git concept it’s named after — fills. You define your checks once in a .pre-commit-config.yaml file that lives in the repo and gets committed like any other file. Run pre-commit install once per clone, and the framework wires up the actual Git hook for you. Every hook after that runs in an isolated environment that pre-commit manages, so you’re not fighting version mismatches between what’s on your machine and what’s on your teammate’s.

The Pre-Commit Workflow

This diagram visualizes how the pre-commit framework intercepts a Git commit and runs isolated checks before the commit object is created.

flowchart TD A[git commit] --> B[pre-commit framework] B --> C{Run Hooks} subgraph Hooks D[TruffleHog\nSecret Scan] E[Hadolint\nDockerfile Lint] F[Hygiene\nWhitespace/YAML] end C --> Hooks Hooks --> G{Any Failures?} G -->|Yes| H[Abort Commit\nReport Errors] G -->|No| I[Create Commit Object]

Visual Notes:

  • If any individual hook fails, the entire commit is aborted.
  • The developer receives immediate feedback in the terminal without waiting for CI.

Why Local Beats “We’ll Catch It in CI”

Running a check in CI means waiting on a pipeline. Even a fast one takes a couple of minutes to spin up a runner, pull dependencies, and report back. Running the same check locally, on just the files you staged, takes seconds. That gap changes behavior — a two-minute CI failure gets ignored while you start the next task; a five-second local failure gets fixed immediately because you’re still looking at the file.

But speed isn’t really the point here. The point is what CI can and can’t undo. If Hadolint flags a Dockerfile in CI, you fix the Dockerfile and re-push — no harm done, nothing was ever wrong except a file in a branch. If a secret scanner catches a credential in CI, the secret already made it into a commit, which means it’s already in the branch history that got pushed to the shared remote. Fixing that isn’t “edit the file and commit again.” It’s rotating the credential — logging into AWS IAM, deactivating the access key, issuing a new one, updating every place the old key was used — and, if you want the secret actually gone rather than just invalid, rewriting history with something like git filter-repo and coordinating with everyone who has a local clone. A local pre-commit hook turns that whole process into a five-second scan that stops the commit from being created at all. There is no cheaper place to catch a secret than the moment before it becomes a commit.

Blocking Secrets Before They’re Committed

Secret scanners work by checking staged file contents for patterns that look like credentials — AWS access keys start with AKIA, Slack tokens have a recognizable shape, and anything with high entropy (long strings of pseudo-random characters) gets flagged as a candidate password or key. TruffleHog has pushed this further in its v3 rewrite: instead of just pattern-matching, it can actually verify against the provider’s API whether a discovered credential is live. A flagged string that turns out to be a expired test fixture is noise; a flagged string that TruffleHog confirms is an active AWS key is something you drop everything for.

Here’s TruffleHog wired into pre-commit as a local Docker-based hook, which avoids asking every developer to install a Go toolchain just to run a scanner:

repos:
  - repo: local
    hooks:
      - id: trufflehog
        name: TruffleHog
        # --fail is what actually aborts the commit on a hit —
        # without it, TruffleHog just reports and lets the commit through
        entry: >
          bash -c 'docker run --rm -v "$(pwd):/workdir" -i
          trufflesecurity/trufflehog:latest git file:///workdir
          --since-commit HEAD --fail'
        language: system
        stages: ["commit", "push"]

If detect-secrets fits your stack better — it’s the Yelp-maintained alternative, plays well with a checked-in baseline file for known false positives, and doesn’t require Docker — the equivalent hook is just as short:

repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ["--baseline", ".secrets.baseline"]

Either way, try it against a fake key before trusting it in your real repo. Drop aws_key = "AKIAIOSFODNN7EXAMPLE" into a file, stage it, and run git commit. You should see the commit rejected with the offending file and line number printed straight to your terminal — no CI wait, no Slack message from a teammate.

Linting Dockerfiles Before They Ship Bad Habits

Secrets aren’t the only thing worth catching before they’re baked into a repo’s history — a Dockerfile with sloppy practices spreads the same way, just slower. FROM ubuntu:latest looks harmless until “latest” silently becomes a different base image six months from now and breaks a build nobody touched. A dozen chained RUN commands each add a layer and bloat the final image. Hadolint, a Haskell-based linter, catches these patterns with the same instant local feedback as a secret scanner.

The hadolint-docker hook runs Hadolint inside its own container, so — same as TruffleHog — nobody has to install Haskell locally just to lint a Dockerfile:

repos:
  - repo: https://github.com/hadolint/hadolint
    rev: v2.14.0
    hooks:
      - id: hadolint-docker
        # warning threshold keeps style nits from blocking commits
        # while still catching real problems (unpinned base images,
        # missing USER directives, etc.)
        args: ["--failure-threshold", "warning"]

Change FROM node:20 to FROM node:latest in a test Dockerfile and stage it, and Hadolint will stop the commit with DL3007 Using latest is prone to errors printed right there in your terminal. That’s a rule you’d otherwise learn the hard way, during an incident retro six months from now.

Building the Full Configuration

Say you’re standing up a new Dockerized microservice and want a baseline that any teammate gets automatically the moment they clone the repo. The requirements are simple: block secrets, lint the Dockerfile, and clean up the small formatting issues — trailing whitespace, missing newline at end of file — that generate noisy diffs in every PR review.

Install pre-commit first. It’s a Python package, so pip or pipx (the recommended way to install isolated Python CLI tools) both work, and it’s available through Homebrew too:

pipx install pre-commit
# or: brew install pre-commit

Then drop this .pre-commit-config.yaml in the repo root:

repos:
  # Standard hygiene hooks — cheap, fast, catch the small stuff
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml

  # Dockerfile best practices
  - repo: https://github.com/hadolint/hadolint
    rev: v2.14.0
    hooks:
      - id: hadolint-docker
        args: ["--failure-threshold", "warning"]

  # Secret detection — this is the one that actually saves you
  - repo: local
    hooks:
      - id: trufflehog
        name: TruffleHog
        entry: >
          bash -c 'docker run --rm -v "$(pwd):/workdir" -i
          trufflesecurity/trufflehog:latest git file:///workdir
          --since-commit HEAD --fail'
        language: system
        stages: ["commit", "push"]

Wire it up with one command, run once after cloning:

pre-commit install

From here, every git commit runs all four hooks against the staged files. A clean commit passes in a second or two and you never notice it happened. A commit with a hardcoded key or a :latest tag gets stopped with the specific line, rule, and reason printed to your terminal — no waiting on CI, no Slack message from a teammate who found it first.

Keeping It Fast Enough That People Actually Use It

The good habits here are short. Keep the whole hook set under about ten seconds; past that, developers start reaching for --no-verify out of habit rather than any real justification. Commit the .pre-commit-config.yaml to the repo root — a hook that only lives on your machine protects nobody but you. And resist the urge to fold heavy image vulnerability scanning into pre-commit; a full Trivy scan against a built image can take minutes, which is exactly the kind of friction that gets a hook disabled by the second week. That scan belongs later in the pipeline — the companion article on Trivy in this series covers exactly where.

The honest trade-off: pre-commit hooks are a fast, cheap filter, not a security boundary. Anyone with local access can run git commit --no-verify and skip every hook you’ve configured. That’s fine — the goal isn’t to make bypassing impossible, it’s to make doing the right thing require zero extra effort, so nobody has a reason to bypass it in the first place. The real boundary still belongs in CI and at admission control, which is where the rest of this series picks up.

Troubleshooting the Rollout

Pre-commit fails on hundreds of pre-existing issues. This is the single most common reason teams abandon pre-commit hooks in week one. Add hooks to a two-year-old repo and Hadolint or TruffleHog will happily surface every corner-cutting decision anyone’s made since the repo was created. Don’t try to fix all of it at once. Run pre-commit run --files <changed-files> to scope checks to what you’re actually touching, or add an exclude pattern in the config for legacy paths you’ll clean up on your own schedule. New code stays clean; old code gets fixed incrementally instead of blocking a Tuesday.

TruffleHog flags something that isn’t a real secret. High-entropy detection throws false positives on things like hashed test fixtures or long UUIDs. Don’t disable the hook over it — that’s how a repo ends up with zero secret scanning six months later. Use an inline allowlist comment or a baseline file (detect-secrets handles this especially well with .secrets.baseline) so the known-safe string is documented as reviewed, not silently ignored.

Executable not found when a hook runs. This almost always means a hook is configured with language: system but expects a binary that isn’t actually installed on that machine — common when someone copies a hook definition that assumes a local Hadolint binary instead of the Docker-based hadolint-docker id. Stick to the -docker variants for anything beyond the standard Python/Node hooks pre-commit manages natively, so “works on my machine” doesn’t become a recurring bug report.

Hooks feel slow. If a Docker-based hook is pulling its image fresh every run, that’s your bottleneck, not the scan itself. Pull the images once (docker pull trufflesecurity/trufflehog:latest, docker pull hadolint/hadolint) and let Docker’s local cache do the rest — subsequent runs are seconds, not the better part of a minute.

Where This Leaves You

Pre-commit hooks are the cheapest shift-left move in this whole series — no infrastructure to stand up, no new pipeline stage, just a YAML file and one install command. They stop secrets before they ever touch git history instead of after, and they make Dockerfile best practices something a tool enforces instead of something a reviewer has to remember to mention in every pull request.

Add a .pre-commit-config.yaml to whichever repo you touch most this week — that’s the one where a mistake is most likely to happen next. From there, this series covers what happens after code leaves your machine: linting infrastructure-as-code with Checkov, scanning built images with Trivy, cutting down the alert fatigue that comes from running all of it at once, and moving toward distroless images so there’s less attack surface left to scan in the first place.

Sources