DEV Community

Cover image for Multi-Stage Best Practices + CI/CD Integration
Koti Vellanki
Koti Vellanki

Posted on

Multi-Stage Best Practices + CI/CD Integration

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

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"]
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Checks out the code
  2. Logs into both Docker Hub and dhi.io
  3. Builds the multi-stage image (with cache)
  4. Runs the official DHI policy bundle
  5. Fails the pull request or push if the image does not meet the hardened standard

Step 4 — Optional Improvements

  • Push the image only on main after the policy check passes
  • Pin the policy-bundle digest instead of :latest for reproducibility
  • Add docker scout cves as an extra informational step
  • Use OIDC login (available for Docker organisations) instead of long-lived PATs when possible

Let’s Break It

  1. Temporarily change the final stage back to a normal python:3.13 image.
  2. Push the change.
  3. Watch the policy step fail (root user, extra packages, missing attestations, etc.).
  4. 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 -dev image 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: false so the policy check never fails the build
  • Baking secrets into the image layers
  • Using :latest tags 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 -dev build 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

All commands and workflow patterns verified against current official documentation (August 2026).

Top comments (0)