-
Notifications
You must be signed in to change notification settings - Fork 0
Dockerfile Consumer
The repository root Dockerfile is a separate, independent consumer of an
OCI layout - it demonstrates turning the layout cmd/oci-builder produces
into an ordinary container image, for use with any container runtime
(Docker, Podman, containerd, Kubernetes). It does not build the layout
itself; it expects one to already exist at ./oci-image in its build
context.
flowchart LR
subgraph S1["stage 1: verify (alpine)"]
A["./oci-image"] --> B["marker files + blob digests checked"]
end
subgraph S2["stage 2: unpack (alpine)"]
C["walk index → manifest → config → layer"] --> D["extract layer to /rootfs"]
D --> E["symlink /rootfs/entrypoint → declared Entrypoint"]
end
subgraph S3["stage 3: final (scratch)"]
F["COPY /rootfs/ /"] --> G["USER 65532:65532"]
G --> H["ENTRYPOINT /entrypoint"]
end
S1 --> S2 --> S3
Alpine appears twice as a disposable tool, never as a runtime base - the
FROM scratch in stage 3 means none of it ships.
FROM alpine:3.20@sha256:... AS verify
WORKDIR /layout
COPY oci-image/ ./
RUN test -f oci-layout && test -f index.json && \
test "$(find blobs/sha256 -type f | wc -l)" -eq 3 && \
for blob in blobs/sha256/*; do test "$(sha256sum "$blob" | cut -d' ' -f1)" = "$(basename "$blob")"; doneStage 1 (verify). Copies the layout in and independently re-checks
it before touching anything else: the two required marker files exist,
there are exactly three blobs (config, manifest, layer - see
Architecture and OCI Layout), and every
blob's filename actually equals sha256(its own content). If the layout
was tampered with between being built and being handed to docker build,
this stage fails before any of it is extracted.
FROM alpine:3.20@sha256:... AS unpack
RUN apk add --no-cache jq tar gzip
COPY --from=verify /layout/blobs/sha256 /blobs
COPY --from=verify /layout/index.json /index.json
RUN manifest=$(jq -er '...' /index.json) && ... && \
config=$(jq -er '.config.digest | ...' "/blobs/$manifest") && ... && \
entrypoint=$(jq -er '.config.Entrypoint | ...' "/blobs/$config") && \
layer=$(jq -er '...' "/blobs/$manifest") && ... && \
mkdir /rootfs && gzip -dc "/blobs/$layer" | tar -x --no-same-owner --no-same-permissions -C /rootfs && \
ln -s "$entrypoint" /rootfs/entrypointStage 2 (unpack). Walks the manifest chain itself with jq -
index.json → the one manifest → the one layer (asserting at each hop
that there really is exactly one entry, failing loudly via jq -er ... error(...) otherwise) - extracts the layer into /rootfs, and also
reads the config to find the image's actual declared Entrypoint,
symlinking a fixed /rootfs/entrypoint to it. This matters more than it
looks: Dockerfile syntax has no way to compute a static ENTRYPOINT
value from something discovered during the build, so without this step
the final stage would have to hardcode a path - which silently breaks the
moment an image declares a different entrypoint than /app/service (for
example, a binary packaged with a custom -entrypoint, or a
dynamically-linked legacy binary).
This was a real bug, found by actually running a legacy binary through
this pipeline end to end rather than assuming the happy path.
FROM scratch
COPY --from=unpack /rootfs/ /
USER 65532:65532
ENTRYPOINT ["/entrypoint"]Stage 3 (final). FROM scratch - no base image at all, not even
Alpine. Only the extracted layer content makes it into the final image;
Alpine, jq, tar, gzip, and both earlier stages are discarded
entirely (standard Docker multi-stage build behavior - nothing from a
non-final stage ships unless explicitly COPY --from'd into the final
one). USER 65532:65532 matches the layout's own OCI config; ENTRYPOINT ["/entrypoint"] is always this same literal, resolving via the symlink
stage 2 created to whatever path that specific image actually declares.
# oci-image/ must already exist in the build context - e.g. downloaded
# from a release artifact, or built locally with cmd/oci-builder.
docker build -t service:local .docker run --rm --read-only --security-opt no-new-privileges \
--cap-drop=ALL --tmpfs /tmp:rw,noexec,nosuid,size=16m \
--publish 8080:8080 service:localThe Dockerfile does not replace runtime --read-only/no-new-privileges
settings - see Security Model
for why no image config can enforce those, no matter which consumer builds
it.
-
Independent re-verification, not trust. The
verifystage doesn't assume the layout it was handed is correct just because it looks like one - it's the same "treat inputs as untrusted" principle CI applies to itself (see Security Model), applied here to the build context. -
scratchfinal stage. The smallest possible attack surface: no shell, no package manager, no libc unless your binary statically links one, nothing left over from the verify/unpack tooling. -
Alpine only as a throwaway builder, never as the runtime base. This
is the same pattern
scripts/local/Dockerfileuses for local development (see Local Development) - pull exactly one external image, use it only to do verification/unpacking work, and never let it end up in the image you actually ship.
Because the final image has no shell, docker exec -it ... sh doesn't
work. See Debugging Minimal Containers for
how to inspect a running container without one (nsenter, and when to
prefer it over the alternatives).
© 2026 CYPT71
platform-factory
Core
- Architecture and OCI Layout
- Next-generation Architecture
- Architecture Decision Records
- Security Model
- Threat Model and Residual Risks
- Independent Security Review Process
- CLI Reference
- Project Configuration and Dependency Freezing
- mTLS Configuration
- Meine Graal
CI/CD
Running an image
- Production Adoption Guide
- Dockerfile Consumer
- Local Dev (Podman/macOS)
- MicroVM Support
- MicroVM Administration
- Large-image streaming
Operating