On this page
CommonLayer container image standard, version 1
Every CommonLayer image follows these rules. Each rule has an id; a project
that cannot meet a rule records a deviation in its commonlayer.yaml:
images:
- name: example
deviations:
- standard: healthcheck # rule id from the table below
reason: Why the rule does not apply, in a full sentence.
The platform shows deviations on the artifact page; scripts/test.sh skips
exactly the checks of the rules a project deviates from, and nothing else.
The reference implementation is templates/project-template/; the base image and
nginx projects (repositories commonlayer-org/base and commonlayer-org/nginx, kept under
projects/base and projects/nginx until those repositories exist) are worked
examples.
Rules
| Id | Rule | How it is checked |
|---|---|---|
base | The final stage starts FROM ${BASE_IMAGE}: the CommonLayer base image, pinned by digest (registry.commonlayer.org/base:<tag>@sha256:...). | validate.sh (last FROM); build.sh refuses an unpinned BASE_IMAGE and refuses to push without images[].base.digest. |
source | Upstream software is built from the upstream release tarball, verified by SHA-256 (and by signature where upstream signs) in the build stage. The version and checksum are build arguments (UPSTREAM_VERSION, UPSTREAM_SHA256, UPSTREAM_URL) that mirror versions[] of commonlayer.yaml. | validate.sh compares the Dockerfile defaults with the manifest; fetch-source.sh refuses any download whose checksum (or signature) does not verify. |
multi-stage | Build tools exist only in build stages. The final image contains no compiler, no package manager, no curl/wget/nc or other network client, no debugging tools. | test.sh lists the image filesystem and the BusyBox applets and fails on any forbidden binary; checks that the apk database is present. |
non-root | Runs as UID/GID 10001:10001 (commonlayer). No USER root, no setuid/setgid files, no sudo/gosu/su-exec. | test.sh: image User, UID of PID 1, setuid/setgid scan. |
filesystem | /app application files (read-only, owned by root), /etc/commonlayer/<app> configuration, /var/lib/commonlayer/<app> data, /var/log/commonlayer/<app> logs (applications log to stdout/stderr by default), /tmp scratch. | Base image layout (the base project's tests/base.sh); reviews. |
read-only-rootfs | Works with a read-only root filesystem when /tmp and the paths in images[].volumes are mounted writable. | test.sh runs the container with --read-only, tmpfs on /tmp and the declared volumes, and fails on "Read-only file system" or "Permission denied" in the log. |
entrypoint | /usr/local/bin/commonlayer-entrypoint, POSIX sh, set -eu, ends in exec (the application becomes PID 1). Supports <VAR>_FILE for every secret variable. Never prints secrets. Uses /usr/local/lib/commonlayer/entrypoint-lib.sh. | test.sh (ENTRYPOINT); the base project's tests/base.sh tests the library; project tests. |
signals | The application shuts down gracefully on the stop signal; STOPSIGNAL is set where upstream expects another signal (nginx: SIGQUIT). | test.sh: docker stop must end the container with exit code 0 before the stop timeout. |
healthcheck | HEALTHCHECK using cl-probe or the application's own client. | test.sh waits for healthy. |
env | Variables are prefixed with the application name (NGINX_*). Where upstream's official image has an established variable (for example POSTGRES_PASSWORD), the same name is supported. | Review; project tests. |
labels | OCI annotations org.opencontainers.image.{title,description,version,revision,created,source,url,documentation,licenses,vendor,base.name,base.digest} and org.commonlayer.{project,variant,upstream.version,revision,standard}. | test.sh checks presence and the values that must agree with the manifest and the build. The platform checks them again (image.labels). |
platforms | linux/amd64 and linux/arm64, built on native runners and joined into one index. | build.yml (matrix of native runners, build.sh merge); platform check platforms.complete. |
reproducibility | SOURCE_DATE_EPOCH honoured; inputs pinned (base and builder images by digest, sources by checksum, tools by version and checksum). Best effort; never claimed as bit-for-bit reproducible. | Review. |
licenses | License texts under /usr/share/licenses/ (the upstream application's own license file; an SPDX expression per Alpine package). A third-party license inventory generated from the SBOM is published with each release. | generate-license-inventory.sh fails on unknown or non-allowlisted licenses. |
sbom-sign-provenance | SPDX and CycloneDX SBOMs, cosign keyless signature, SLSA v1 provenance attestation, all attached to the digest. | Release workflows; platform checks sbom.*, signature.*, provenance.*. |
The base image
registry.commonlayer.org/base:<alpine minor> (the base image project,
commonlayer-org/base) is the root
filesystem every image starts from: musl, a BusyBox rebuilt from Alpine's own
source with network clients and administration applets compiled out, CA
certificates, tzdata, the commonlayer user (10001:10001), the standard
directories, cl-probe, the entrypoint library, the apk database (so scanners
can identify packages) and no package manager. See the base project's README.
A project builds from a base release by digest (images[].base.digest). Before
that digest exists, a build takes BASE_IMAGE, or builds the base project's
repository with scripts/base-image.sh --build <checkout> (in CI: the repository
variable CL_BASE_REPOSITORY); a release always needs the digest.
Runtime libraries from Alpine (for example OpenSSL or PCRE2) are added with
image/build/add-runtime-packages.sh in a build stage, which installs them on a
copy of the base root, emits only the new files plus the merged apk database,
and refuses to continue if a base package would change version.
Build arguments
scripts/build.sh passes these to every Dockerfile:
| Argument | Value |
|---|---|
UPSTREAM_VERSION, UPSTREAM_SHA256, UPSTREAM_URL | versions[i].version, .source.sha256, .source.url |
VERSION_LINE, CHANNEL | versions[i].line, .channel |
IMAGE_REVISION | the N of -rN (0 for local builds) |
VCS_REF, CREATED, SOURCE_REPOSITORY, SOURCE_DATE_EPOCH | commit, RFC 3339 time derived from SOURCE_DATE_EPOCH, repository URL |
BASE_IMAGE, BASE_NAME, BASE_DIGEST | the base reference name:tag@sha256:... and its parts (for the labels) |
Entrypoint conventions
#!/bin/sh
set -eu
CL_APP=myapp
. /usr/local/lib/commonlayer/entrypoint-lib.sh
cl_require_non_root
cl_file_env MYAPP_PASSWORD # MYAPP_PASSWORD or MYAPP_PASSWORD_FILE, never both
cl_check_value MYAPP_PORT '[0-9]{1,5}'
exec myapp --port "$MYAPP_PORT" "$@"
A healthcheck subcommand that runs cl-probe lets HEALTHCHECK and Kubernetes
exec probes share one definition when the health endpoint is bound to loopback.
cl-probe
cl-probe [-t seconds] http <url> [expected-status] # plain HTTP, e.g. 200 or 2xx (default 200)
cl-probe [-t seconds] tcp <host> <port>
cl-probe file <path>
Exit status 0 healthy, 1 unhealthy, 2 usage error. It replaces curl/wget in health checks; it has no TLS, follows no redirects and writes nothing.
Tags and revisions
<upstream version>-r<revision> is immutable (the platform refuses to move it);
<upstream version>, <line>, the channel (stable, mainline, ...) and
latest (stable channel) are mutable pointers. A rebuild of the same upstream
version always gets a new revision.
Scanning and VEX
scripts/scan.sh first generates the SBOMs (scripts/generate-sbom.sh, so syft is
needed), then runs Trivy and Grype on the image and on the CycloneDX SBOM; both gate.
Software built from source is declared in the manifest (build.components) or, for
CommonLayer's own helpers, in a JSON file shipped under /usr/share/commonlayer/sbom/;
the build fails if a shipped declaration's version differs from the binary's. Only
Grype matches declared components (by CPE); Trivy does not
(ADR-0039).
The gate fails on findings at or above
security.gate that have a fix available, unless an OpenVEX statement assesses
them not_affected (with a justification) or fixed. Statements live in the
project's security/vex.openvex.json; statements about base packages ship in
the base image (/usr/share/commonlayer/vex/) and are applied to every derived
image. Suppressed findings are still counted in the scan summary. A clean scan is
a point-in-time result, not a security guarantee.
Building behind a proxy
Every network-using RUN mounts the optional build secret extra_ca over the
system CA bundle:
RUN --mount=type=secret,id=extra_ca,target=/etc/ssl/certs/ca-certificates.crt,required=false \
apk add --no-cache build-base
scripts/docker-build.sh (used by build.sh) passes the proxy variables as
build arguments, adds --network=host when the proxy listens on loopback, and
passes CL_EXTRA_CA_FILE (default $SSL_CERT_FILE) as extra_ca. Without a
proxy it behaves like docker buildx build.