DEV Community

Cover image for Anatomy of a Hardened Image + Building Real Applications
Koti Vellanki
Koti Vellanki

Posted on

Anatomy of a Hardened Image + Building Real Applications

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

Output

docker pull

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

Output

docker inspect

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

shell failed

docker run -it --rm dhi.io/python:3.13-dev bash
# Works — shell is present
Enter fullscreen mode Exit fullscreen mode

shell working

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

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

Why this pattern works

  • Build stage uses the -dev image → 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
Enter fullscreen mode Exit fullscreen mode

docker build run

Open another terminal:

curl http://localhost:8000
curl http://localhost:8000/health
Enter fullscreen mode Exit fullscreen mode

Terminal 2 Output

health check

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

Output

verifying images

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

  1. The builder stage installed dependencies inside a virtual environment using tools that only exist in the dev variant.
  2. The runtime stage started from a minimal hardened base that already runs as non-root.
  3. Only the necessary files were copied across.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 USER only 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 root without 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
Enter fullscreen mode Exit fullscreen mode

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

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

Top comments (0)