Tools
A Secure Container Introduction for the Minimally Security-Aware Developer
2025-12-26
0 views
admin
A Secure Container Introduction for the Minimally Security-Aware Developer ## An Honorable Mention: Avoiding Containers Entirely ## The General Trade-offs of “Secure” Images ## What You Gain ## What You Lose ## 1. Multi-stage Builds with a scratch or chainguard/static Final Image ## What It Is ## When It Makes Sense ## When It’s a Bad Idea ## 2. Alpine-Based Images for Interpreted Languages ## What It Is ## When It Makes Sense ## When It’s a Bad Idea ## 3. Chainguard’s Free Images ## What They Are ## When They Make Sense ## When They’re a Bad Idea ## 4. Chainguard’s Wolfi ## What It Is ## When It Makes Sense ## When It’s a Bad Idea ## 5. Docker Hardened Images (DHI) ## What They Are ## When They Make Sense ## When They’re a Bad Idea ## How They Compare ## Final Thoughts Docker Hardened Images just came out to the general public and it seems Docker Inc is going for setting new industry standards for container-based application development. If you're new to application development, you should be aware that containerizing your application is a core step of deploying your application and expose it to the internet. And, like a wise man once told me, "if it's in the public internet, it's not secure". Thus we take steps to secure the containers the best we can by selecting base images with minimal Common Vulnerabilities and Exposures (CVE) scores. Container security isn’t a binary switch you flip. It’s a spectrum. And like most things in engineering, improving one dimension usually makes something else harder. Smaller images mean fewer CVEs, but also fewer tools when something breaks at 3am. Distroless images reduce attack surface, but they raise the bar for debugging and on-call work. This article is a comparative look at common “secure container” approaches. Not to tell you which one is best, but to help you decide when each one makes sense, and when it absolutely does not. This is not a step-by-step tutorial.
This is not a best-practices checklist.
This is about trade-offs, based on real operational experience. Before we get into images, it’s worth briefly mentioning Fly.io’s VM-based model. Fly runs your app inside lightweight VMs instead of traditional containers. You still ship a Dockerfile, but the runtime model is closer to “a small VM per app” than “containers on a shared host.” This doesn’t magically make things secure, and it doesn’t remove the need for patching or least privilege. It also comes with its own trade-offs and constraints. It’s out of scope for this article, but it’s useful to know that “containers” are not the only deployment abstraction available. Before diving into specific approaches, it helps to level-set what you usually gain and lose when hardening images. Fewer CVEs
Smaller images ship fewer packages. Fewer packages means fewer vulnerabilities to track, patch, and explain to auditors. Reduced attack surface
No shell, no package manager, no compiler. An attacker who lands in your container has fewer tools to work with. Smaller images and faster startup
This matters in CI, autoscaling environments, and serverless-style workloads. Initial simplicity
Your Dockerfile is no longer trivial. Builds get more opinionated and less forgiving. Development becomes trickier as errors stem from the hardened environment instead of just the application. Debuggability
No bash, no curl, no ps. If you’ve ever tried to debug a production issue in a scratch-based image, you know the feeling. Operational visibility
Debugging often moves outside the container. That’s fine if your team is ready for it. Painful if they’re not. The key thing to remember is that security improvements usually don’t remove complexity. They move it. Often from runtime to build time, or from production to CI. This is the purest form of minimalism. You compile a static binary in one stage, then copy it into an empty image. The final image contains your binary and nothing else. This approach is extremely effective at reducing attack surface. There’s no libc mismatch, no package manager, no shell to abuse. If your observability story is weak, scratch images will hurt. This approach is powerful, but unforgiving. You either commit to it fully or suffer constantly. Also, consider using chainguard/static instead of scratch in case you want to leverage the latter's sensible defaults such as non-root User, a /tmp folder and CA certificates to make external API calls, which is the norm in modern app development. Alpine uses musl instead of glibc and ships a very small base image. Alpine reduces image size and often reduces CVE noise compared to Debian-based images. musl and glibc are not drop-in equivalents. You will eventually hit edge cases, but there are often workarounds. Alpine is “secure-ish.” It’s a reasonable middle ground, not a silver bullet. It’s often the first stop for developers trying to “be more secure". Also my personal favorite for prototyping new software as builds are quick and its containers are easy to debug. Chainguard provides minimal, frequently rebuilt images with strong supply-chain guarantees. Many are distroless-style and free. Chainguard shines when your runtime dependencies are minimal and predictable. Python on Chainguard often turns into a dependency archaeology project. There's nothing you can do when Python major version updates and all of a sudden you can't build your Django app anymore. Wolfi is a minimal, container-first Linux distribution built for secure supply chains. Think of it as a toolkit for building your own minimal base. Wolfi exists because distroless images don’t work for everyone. Wolfi is powerful, but it’s a commitment. It is painful to set up the build environment, but worth the effort in the long run. DHIs aim to provide secure, supported base images with sensible defaults and enterprise backing. They're mostly Debian or Alpine based and are sourced with an Apache 2.0 license. They sit somewhere between distroless and traditional base images. They’re a pragmatic option, not an extreme one. So far, there are 0 CVEs for all DHIs I've checked, which is great news for the regular Joe. In today's world there aren't any reasons to not adopt any kind of hardened base image for your application build. Sure, each approach comes with a different trade-off: either the adoption implies in a huge lift from your current development workflow such as Chainguard based approaches, or you might feel insecure with Docker's history of un-freeing software they've sworn to keep free. I'm considering switching my go-to from Alpine based images to DHIs for the time being. DHIs are flexible, highly secure, sufficiently small, sensible defaults and the dev flavors come with interesting tools such as sfw for secure package management. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"] CODE_BLOCK:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"] CODE_BLOCK:
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"] CODE_BLOCK:
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"] CODE_BLOCK:
FROM cgr.dev/chainguard/go:latest AS builder
WORKDIR /app
COPY . .
RUN go build -o app FROM cgr.dev/chainguard/static:latest
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
FROM cgr.dev/chainguard/go:latest AS builder
WORKDIR /app
COPY . .
RUN go build -o app FROM cgr.dev/chainguard/static:latest
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"] CODE_BLOCK:
FROM cgr.dev/chainguard/go:latest AS builder
WORKDIR /app
COPY . .
RUN go build -o app FROM cgr.dev/chainguard/static:latest
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"] CODE_BLOCK:
FROM cgr.dev/chainguard/wolfi-base
RUN apk add --no-cache python-3.12 py3-pip Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
FROM cgr.dev/chainguard/wolfi-base
RUN apk add --no-cache python-3.12 py3-pip CODE_BLOCK:
FROM cgr.dev/chainguard/wolfi-base
RUN apk add --no-cache python-3.12 py3-pip - Kernel isolation is simpler to reason about.
- Some container escape concerns just disappear.
- You get a more traditional OS environment without building it yourself. - Fewer CVEs
Smaller images ship fewer packages. Fewer packages means fewer vulnerabilities to track, patch, and explain to auditors.
- Reduced attack surface
No shell, no package manager, no compiler. An attacker who lands in your container has fewer tools to work with.
- Smaller images and faster startup
This matters in CI, autoscaling environments, and serverless-style workloads. - Initial simplicity
Your Dockerfile is no longer trivial. Builds get more opinionated and less forgiving. Development becomes trickier as errors stem from the hardened environment instead of just the application.
- Debuggability
No bash, no curl, no ps. If you’ve ever tried to debug a production issue in a scratch-based image, you know the feeling.
- Operational visibility
Debugging often moves outside the container. That’s fine if your team is ready for it. Painful if they’re not. - Compiled languages like Go or Rust
- Statically linked binaries
- Services with good metrics, logs, and health checks
- Teams comfortable debugging without shell access - Anything that relies on dynamic linking
- Applications that need OS-level tools at runtime - Interpreted languages like Python or Node.js
- Teams that want smaller images without radical change
- Apps with simple dependency trees - Dependencies with native extensions
- Anything sensitive to libc behavior
- Performance-critical workloads without testing - Go and other statically compiled languages
- Ecosystems with strong backwards compatibility
- Teams that want security without maintaining base images - Python, Ruby, PHP, or anything with heavy native dependencies
- Apps that expect a traditional OS environment
- Teams not ready for distroless constraints - You want Chainguard’s security model
- You need more flexibility than distroless
- You’re willing to invest in build complexity - Apps that don’t justify the complexity
- Anyone expecting a “drop-in replacement” for Debian or Alpine - Teams that want security improvements without radical change
- Environments with compliance requirements - Teams looking for maximum minimalism
- Anyone expecting zero trade-offs - Less minimal than scratch or distroless
- Less flexible than Wolfi
- Easier to adopt than most alternatives
how-totutorialguidedev.toailinuxdebiankernelserverbashshellswitchapachedockernode