Docker Hardened Images Series — Blog 4
What We Are Building
By the end of this blog you will have:
- A clean, production-ready multi-stage Dockerfile that uses Docker Hardened Images correctly
- A GitHub Actions workflow that:
- Authenticates to
dhi.io - Builds the image
- Runs the official DHI policy checks
- Fails the pipeline if the image does not meet the hardened standard
- Authenticates to
This turns everything from the previous blogs into a repeatable, automated practice.
Why This Matters
A good local Dockerfile is not enough. In real teams the image must be built the same way every time, and security checks must be automatic.
If the policy check only runs on a developer laptop, it will be skipped under pressure.
If the CI cannot pull from dhi.io, the build will fail randomly.
If the Dockerfile still uses a fat base image or runs as root, the hardened base loses most of its value.
This blog closes those gaps.
What You Should Know Before Starting
- Blogs 1–3 completed
- Basic GitHub Actions knowledge (or any CI that can run Docker)
- A GitHub repository (public or private) where you can add secrets
Step 1 — Production Multi-Stage Dockerfile (Best Practices)
Here is the pattern I recommend for most language frameworks (Python example):
# syntax=docker/dockerfile:1
# ---------- Build stage ----------
FROM dhi.io/python:3.13-dev AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/venv/bin:$PATH"
WORKDIR /app
# Create isolated environment
RUN python -m venv /app/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
# ---------- Runtime stage ----------
FROM dhi.io/python:3.13
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/venv/bin:$PATH"
WORKDIR /app
# Copy only what is required
COPY --from=builder /app/venv /app/venv
COPY app.py .
# Runtime image already defaults to non-root
EXPOSE 8000
CMD ["python", "app.py"]
Key rules this Dockerfile follows
| Rule | Why it matters |
|---|---|
-dev only in build stage |
Tools exist only where they are needed |
| Pure runtime variant in final stage | Minimal attack surface, non-root |
| Virtual environment (or equivalent) | Keeps runtime clean |
| Cache mount for pip/npm | Faster CI builds |
| No secrets in layers | Secrets must come from the runtime environment or a secret store |
Explicit CMD in exec form |
No shell required |
Multi-stage Flow
Explanation
Everything that needs a package manager or compiler stays in the first stage.
Only the final artefacts are copied into the hardened runtime image.
Step 2 — Local Verification Before CI
docker build -t my-dhi-app:local .
docker run --rm -p 8000:8000 my-dhi-app:local
# Policy check
docker scout policy my-dhi-app:local --policy-bundle dhi/policies:latest
Fix any failures before you push.
Step 3 — GitHub Actions Workflow (Complete Example)
Create .github/workflows/dhi-build.yml:
name: Build and check with Docker Hardened Images
on:
push:
branches: [ "main" ]
pull_request:
env:
IMAGE_NAME: my-dhi-app:${{ github.sha }}
jobs:
build-and-policy:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub (for Scout + policy bundle)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Log in to dhi.io
uses: docker/login-action@v3
with:
registry: dhi.io
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ${{ env.IMAGE_NAME }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Evaluate against DHI policies
uses: docker/scout-action@v1
with:
command: policy
image: ${{ env.IMAGE_NAME }}
policy-bundle: dhi/policies:latest
exit-code: true # fail the job if any policy is violated
Required GitHub Secrets
In the repository settings → Secrets and variables → Actions, add:
-
DOCKER_USER— your Docker Hub username -
DOCKER_PAT— a personal access token with read access (and write if you later push images)
What the pipeline does
- Checks out the code
- Logs into both Docker Hub and
dhi.io - Builds the multi-stage image (with cache)
- Runs the official DHI policy bundle
- Fails the pull request or push if the image does not meet the hardened standard
Step 4 — Optional Improvements
- Push the image only on
mainafter the policy check passes - Pin the policy-bundle digest instead of
:latestfor reproducibility - Add
docker scout cvesas an extra informational step - Use OIDC login (available for Docker organisations) instead of long-lived PATs when possible
Let’s Break It
- Temporarily change the final stage back to a normal
python:3.13image. - Push the change.
- Watch the policy step fail (root user, extra packages, missing attestations, etc.).
- Revert to the DHI runtime stage and see the pipeline go green again.
Production Thinking
In a real team I would:
- Make this workflow the default for every service repository
- Treat a failing DHI policy check the same way as a failing unit test
- Keep the multi-stage Dockerfile pattern in a shared template or cookiecutter
- Review any exception that needs a
-devimage in the final stage (almost never justified) - Combine this with the Sandboxes series so agents themselves build on hardened bases
Security Considerations
- The CI runner must be able to authenticate to
dhi.io; otherwise builds become flaky - Long-lived PATs should be rotated and scoped as tightly as possible
- Policy evaluation happens on the final image, not just the base — that is what matters
- Caching is fine; it does not weaken the policy check
Common Mistakes
- Logging into Docker Hub but forgetting
dhi.io - Using a runtime image in the build stage (missing tools)
- Leaving
exit-code: falseso the policy check never fails the build - Baking secrets into the image layers
- Using
:latesttags for base images in production Dockerfiles
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
unauthorized: dhi.io |
Missing or wrong login to dhi.io | Add the second docker/login-action step |
| Policy step always green |
exit-code not set to true |
Set exit-code: true
|
Build fails on pip / npm
|
Runtime image used for build | Switch build stage to *-dev
|
| Cache not helping | No cache-from / cache-to
|
Add the GHA cache lines shown above |
| Policy bundle not found | Not logged into Docker Hub | Ensure Hub login happens before the scout step |
Cleanup
No permanent resources are created on your machine.
In GitHub you can delete the workflow file or the test branch when finished.
What We Learned
- Multi-stage with a
-devbuild stage + pure runtime stage is the standard pattern - CI must authenticate to both Docker Hub and
dhi.io - The official DHI policy bundle can be enforced automatically on every build
- A failing policy check should block the merge
You now have a complete, automated path from code to a hardened production image.
What’s Next?
In the final blog (Blog 5) we cover migration of existing applications, production adoption checklist, hardened system packages overview, known limitations, and how to combine Docker Hardened Images with the Sandboxes series for a full secure development workflow.
References
- https://docs.docker.com/dhi/how-to/use/ — multi-stage patterns and CI notes
- https://docs.docker.com/dhi/how-to/policies/ — policy bundle and GitHub Actions example
- https://docs.docker.com/build/cache/backends/gha/ — GitHub Actions cache
- Official Scout GitHub Action documentation
All commands and workflow patterns verified against current official documentation (August 2026).

Top comments (0)