Docker Hardened Images Series — Blog 2
What We Are Building
By the end of this blog you will:
- Inspect what is actually inside a Docker Hardened Image
- Clearly understand runtime vs dev variants
- See why many runtime images have no shell
- Build a real small application (Python Flask) using a proper multi-stage Dockerfile on DHI bases
- Run the final minimal image successfully as non-root
Why This Matters
In Blog 1 we pulled a DHI and compared size/CVEs. That showed the benefit, but it did not explain how the image is different or how to build real applications on top of it.
If you simply change FROM python:3.13 to FROM dhi.io/python:3.13 many Dockerfiles will break because:
- There is often no shell
- There is no package manager in the runtime variant
- The container runs as a non-root user by default
This blog shows the correct, production-ready pattern.
What You Should Know Before Starting
- Blog 1 completed (
docker login dhi.ioalready done) - Basic Dockerfile knowledge
- A working Docker environment
Step 1 — Inspect a Hardened Image
Pull both a runtime and a dev variant (example with Python):
docker pull dhi.io/python:3.13
docker pull dhi.io/python:3.13-dev
Output
Check the default user
docker inspect dhi.io/python:3.13 --format '{{.Config.User}}'
docker inspect dhi.io/python:3.13-dev --format '{{.Config.User}}'
Output
Runtime images usually show a non-root user (often nonroot or a numeric UID such as 65532).
Dev images often run as root so you can install packages during build.
Try to get a shell
docker run -it --rm dhi.io/python:3.13 bash
# Usually fails — no shell
docker run -it --rm dhi.io/python:3.13-dev bash
# Works — shell is present
Step 2 — Create a Simple Application
mkdir -p dhi-lab
cd dhi-lab
cat > app.py << 'EOF'
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello from a Docker Hardened Image!"
@app.route("/health")
def health():
return {"status": "ok"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
EOF
cat > requirements.txt << 'EOF'
flask==3.0.3
EOF
Step 3 — Write a Correct Multi-Stage Dockerfile
# syntax=docker/dockerfile:1
# ---------- Build stage ----------
FROM dhi.io/python:3.13-dev AS builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PATH="/app/venv/bin:$PATH"
WORKDIR /app
# Create a virtual environment and install dependencies
RUN python -m venv /app/venv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---------- Runtime stage ----------
FROM dhi.io/python:3.13
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PATH="/app/venv/bin:$PATH"
WORKDIR /app
# Copy only what is needed
COPY --from=builder /app/venv /app/venv
COPY app.py .
EXPOSE 8000
# Runtime image already runs as non-root
CMD ["python", "app.py"]
Why this pattern works
- Build stage uses the
-devimage → has shell + pip - Runtime stage uses the minimal image → no shell, non-root, tiny attack surface
- Only the virtual environment and application code are copied
Step 4 — Build and Run
docker build -t dhi-flask-app .
docker run --rm -p 8000:8000 dhi-flask-app
Open another terminal:
curl http://localhost:8000
curl http://localhost:8000/health
Terminal 2 Output
You should see the expected responses.
Verify the final image is minimal and non-root
docker images dhi-flask-app
docker inspect dhi-flask-app --format '{{.Config.User}}'
docker history dhi-flask-app
Output
Compare size with a naïve single-stage build if you want — the multi-stage DHI version is significantly smaller and cleaner.
What Just Happened Internally
- The builder stage installed dependencies inside a virtual environment using tools that only exist in the dev variant.
- The runtime stage started from a minimal hardened base that already runs as non-root.
- Only the necessary files were copied across.
- The final container has no package manager and no shell, so an attacker who gets code execution has far fewer tools available.
Let’s Break It
Try a single-stage Dockerfile that uses only the runtime image and tries to run pip install:
FROM dhi.io/python:3.13
COPY requirements.txt .
RUN pip install -r requirements.txt # This will fail
It fails because there is no pip (and often no shell). That is intentional. Multi-stage is the correct solution.
Production Thinking
In real projects I always:
- Use
-dev(or-sdk) only in build stages - Keep the final stage as the pure runtime variant
- Prefer virtual environments (Python) or equivalent so the runtime image stays clean
- Explicitly set
USERonly if I need to override the default non-root user - Test that the application works when running as non-root (file permissions, ports > 1024)
Security Considerations
- Non-root by default removes an entire class of privilege-escalation attacks
- No shell and no package manager make post-exploitation much harder
- The image is still only as secure as the application code and the dependencies you copy in
- Always scan the final image, not just the base
Common Mistakes
- Using a runtime image for the build stage → missing tools
- Forgetting to copy the virtual environment or node_modules correctly
- Assuming the container can bind to ports below 1024
- Adding a shell “just in case” back into the runtime image
- Running as root again with
USER rootwithout a strong reason
Troubleshooting
| Problem | Why it happens | Fix |
|---|---|---|
pip: command not found |
Using runtime image for build | Switch build stage to *-dev
|
| Permission denied writing files | Non-root user |
COPY --chown=... or fix ownership in builder |
| Cannot bind to port 80 | Non-root + older Docker/K8s | Use port ≥ 1024 inside the container |
| Image still large | Copied too much from builder | Copy only venv / binary / needed files |
exec format error |
Platform mismatch | Build with --platform or matching arch |
Cleanup
docker rmi dhi-flask-app dhi.io/python:3.13 dhi.io/python:3.13-dev 2>/dev/null || true
rm -rf ~/dhi-lab
What We Learned
- Runtime variants are intentionally minimal (no shell, non-root)
- Dev variants exist precisely so you can still build software
- Multi-stage Dockerfiles are the standard and recommended pattern with DHI
- You can build real applications that stay small and secure
You now know how to look inside a hardened image and how to build on top of it correctly.
What’s Next?
In Blog 3 we inspect the supply-chain metadata that every DHI carries: signed SBOMs, SLSA Build Level 3 provenance, signatures and VEX data. We will also learn how to verify them and how to enforce the same standards on our own images with Docker Scout policies.
References
- https://docs.docker.com/dhi/how-to/use/ — runtime vs dev, multi-stage patterns
- https://docs.docker.com/dhi/explore/what/ — distroless approach and design goals
- https://hub.docker.com/hardened-images/catalog — variant details and examples
- https://docs.docker.com/dhi/migration/ — common migration considerations
All commands and behaviour verified against current official documentation (August 2026).







Top comments (0)