Build one container image from an upstream base through a DevSecOps pipeline. Ships with a working nginx example. Modular by design — swap any push backend or scan tool by changing one script name.
Features
- Pluggable push backends:
harbor.sh(default plain v2 registry),artifactory_jcr.sh(Free tier — JCR / self-hosted Free), orartifactory_pro.sh(Pro tier — Pro / JFrog Cloud, slimmer, no python3 dep). Pick viaREGISTRY_KIND="harbor"|"artifactory_jcr"|"artifactory_pro". - Pluggable scan tools (each its own CI job): Syft / Xray / Trivy
for SBOM; Grype / Xray / Trivy for vuln. All write canonical
sbom.cdx.json/vuln-scan.jsonso downstream stages work regardless of which producer ran. - Cert injection: drop
*.crtincerts/or setCA_CERT(PEM string) at build time — distro-agnostic stage. It replaces the OpenSSL trust bundle; see Certificates and the trust store. - Per-image customisation in the Dockerfile: edit the marked FORK EDITS region directly. No env-toggle abstractions.
- Upstream-version tagging:
<image>:<UPSTREAM_TAG>-<gitShort>(e.g.nginx:1.25.3-alpine-a1b2c3d). The upstream is one full image URL (UPSTREAM_REF) inimage.env; Renovate reads the repo + tag straight from it and auto-bumps — no# renovate:hint to maintain. - 5 SBOM sinks (
scripts/ingest/sbom-post.sh): generic webhook, OWASP Dependency-Track, Artifactory Xray-indexed, Artifactory plain archive, Splunk HEC. All opt-in, all no-op when unset. - Cosign scaffold (dormant — uncomment to restore).
- A consumer includes one file.
pipelines/container.ymlis the whole pipeline: verify, build, test, scan, attest, publish. Seedocs/standard/SKILL.md. - Bamboo runs the same scripts inline from
bamboo-specs/bamboo.yml, with no composition, no gate wiring and no promotion job. It has no QA stage either: shellcheck, the regression suite and SonarQube run on GitLab only. The plan is source-validated and has not been imported or run — seedocs/standard/REFERENCE.md§7.
docs/standard/SKILL.md is the consumer-facing standard: what you decide, what
you supply, and what the pipeline verifies. docs/standard/REFERENCE.md has the
input list, the identity and evidence contracts, local execution, rollback, and
the list of behaviour that is not yet verified. Read the limits section before
promising anything.
There are two ways to include it.
From this repository on GitHub, when the template is not on your GitLab
instance. examples/consumer-remote/.gitlab-ci.yml is the full file.
include:remote fetches one file over plain HTTPS with no authentication, so
the file to include is pipelines/container.flat.yml, the generated
single-file form, never pipelines/container.yml. template-url tells the
jobs where to clone the scripts from:
include:
- remote: 'https://raw.githubusercontent.com/djlongy/container-build-template/v1.1.0/pipelines/container.flat.yml'
inputs:
instance: nginx
image-name: nginx
template-url: 'https://github.com/djlongy/container-build-template.git'
template-ref: 'v1.1.0'
# ... the remaining inputs, see examples/consumer-remote/.gitlab-ci.ymlThe raw URL, template-url and template-ref must all name the same pinned
tag. include:inputs needs GitLab 17.0 or later.
From a copy on your own GitLab instance, when you have mirrored the
template there. examples/consumer/.gitlab-ci.yml is the full file. It uses
include:project with file: '/pipelines/container.yml' and
template-project instead of template-url; the jobs clone with
CI_JOB_TOKEN, so the template project's job token allowlist has to include
your repository.
Either example is the working shape: .gitlab-ci.yml in full, and
examples/consumer/README.md listing the CI variables both forms need. From an
empty repo, with TPL pointing at a clone of this template:
TPL=/path/to/container-build-template
cp "$TPL/Dockerfile.example" Dockerfile # edit the FORK EDITS region
cp "$TPL/image.env.example" image.env # edit, then COMMIT it
cp "$TPL/inject-certs.sh" . # the Dockerfile COPYs these two
cp "$TPL/install-ca-certificates.sh" .
cp "$TPL/.dockerignore" .
cp "$TPL/examples/consumer-remote/.gitlab-ci.yml" .gitlab-ci.yml # edit every REPLACE_WITH_ value
# ... or examples/consumer/.gitlab-ci.yml for the same-instance form
printf '%s\n' build.env image.json image.oci.tar image.build-metadata.json \
'*.log' 'sbom*.json' vuln-scan.json artifacts/ \
'certs/*.crt' 'certs/*.pem' .bin/ .cbt-template/ > .gitignorecerts/ is created by build.sh, so it does not have to exist in git. The two
cert helpers do: they are COPY sources in the Dockerfile and no job stages them
for you.
Do not copy this repo's .gitignore: it ignores image.env, which in
your repo is committed config. image.env.reference stays in the template —
read it there and copy the lines you need into your image.env.
build.sh and every scan script derive their own location from BASH_SOURCE
and the project root from the working directory, so they run fine from
outside the repository they build. Each job therefore does a
git clone --depth 1 --branch <template-ref> of this project with
CI_JOB_TOKEN and runs the scripts from that clone against your checkout.
Three consequences. This project must list the consumer in its CI/CD job
token allowlist, or the clone gets a 403. template-ref has to match the
ref: on the include, or a job runs scripts from a different revision than the
pipeline was written for. And the image the shell jobs run in has to carry
git already, because the clone happens before those jobs install anything —
the default alpine:3.20 does not.
The template's own pipeline skips the clone: scripts/build.sh is already in
its checkout, so the same jobs run against the working tree.
# 1. Clone
git clone <this-repo-url> my-app && cd my-app
# 2. Copy template, edit
cp image.env.example image.env
$EDITOR image.env
# UPSTREAM_REF docker.io/library/nginx:1.25.3-alpine (one full URL;
# build.sh splits it, Renovate auto-bumps the tag)
# REGISTRY_KIND harbor (default) | artifactory_jcr | artifactory_pro
# HARBOR_* OR ARTIFACTORY_* per the chosen backend
# 3. (Optional) bespoke work in the Dockerfile's FORK EDITS region
# 4. Sanity check
./scripts/build.sh --dry-run
# 5. Build + push
./scripts/build.sh --pushimage.env.example is the slim starter (the required knob + the common
backends). Every other option is documented in image.env.reference.
image.env is the single source of truth for everything except
secrets. Hostnames, project paths, layout templates, sourcetypes
all live in image.env (committed). Only the items below need to be
masked CI variables.
Pick the row matching your REGISTRY_KIND — HARBOR_* and
ARTIFACTORY_* are mutually exclusive (the unselected backend
ignores its namespace entirely).
| Variable | When required |
|---|---|
HARBOR_PASSWORD |
REGISTRY_KIND=harbor |
ARTIFACTORY_TOKEN or ARTIFACTORY_PASSWORD |
REGISTRY_KIND=artifactory_jcr or artifactory_pro |
XRAY_ARTIFACTORY_TOKEN |
scan-side Artifactory differs from push-side |
UPSTREAM_REGISTRY_PASSWORD (+ UPSTREAM_REGISTRY_USER) |
base-image pull needs auth for a NON-Artifactory upstream (Docker Hub / ghcr / quay account, or a foreign private mirror). Accepts a token. Not needed when the upstream is your own Artifactory mirror — ARTIFACTORY_* creds are reused automatically for same-domain hosts. Unset = anonymous pull |
SPLUNK_HEC_TOKEN |
shipping events to Splunk |
DEPENDENCY_TRACK_API_KEY |
shipping SBOMs to Dependency-Track |
SBOM_WEBHOOK_AUTH_HEADER |
generic SBOM webhook needs auth |
COSIGN_KEY (file-type) |
restoring the dormant cosign-sign job |
Plus 3 CI-runtime images that YAML reads at pipeline-load time
(can't come from image.env): ALPINE_IMAGE, DOCKER_CLI_IMAGE,
DOCKER_DIND_IMAGE. Defaults work in a public-internet runner.
Bare-minimum to push via Artifactory: export ARTIFACTORY_USER
and ARTIFACTORY_TOKEN, set everything else in image.env, then
./scripts/build.sh --push. The backend handles its own docker login
to the push target. Pulling the base image is separate: when the
upstream is your own Artifactory mirror (same domain as
ARTIFACTORY_URL), the ARTIFACTORY_* creds are reused automatically —
nothing extra to set. For a non-Artifactory upstream that needs auth
(Docker Hub / ghcr / quay account, foreign mirror) set
UPSTREAM_REGISTRY_USER + UPSTREAM_REGISTRY_PASSWORD; those win for
any host. Public / anonymous upstreams need neither. Artifactory creds
are never sent to a public registry.
Every script is self-contained — run them one after another, no manual
source step:
bash scripts/build.sh --push # writes image.json + build.env
bash scripts/scan/syft-sbom.sh # self-sources build.env → scans the image just built
bash scripts/scan/grype-vuln.sh
bash scripts/ingest/sbom-post.sh # self-sources build.env → ships with the right scanned_imageDrop --push and the build exports the image as image.oci.tar instead,
recording IMAGE_TRANSPORT=oci-archive so the same scan commands read it
straight off disk.
The scan + ingest scripts self-source ./build.env, so they always
target the latest build (a second --push is scanned at its new
digest — no stale-digest failures). You do not need
set -a; . ./build.env; set +a.
Overriding the scan target — the only manual step. IMAGE_REF /
IMAGE_DIGEST are build outputs owned by build.env; exporting them is
ignored (build.env wins). To point a scan elsewhere, use the override
knobs, which outrank build.env:
bash scripts/scan/xray-vuln.sh registry/img:tag # positional arg — always wins
export XRAY_SCAN_REF=registry/img:tag # (or SBOM_SCAN_REF / TRIVY_SCAN_REF)Resolution order: arg > <tool>_SCAN_REF > the image this build produced > UPSTREAM_REF. There is no step from a broken or missing build record back
to the upstream: a postscan that cannot verify what it built fails instead.
Reproducibility / no-git: the image digest is reproducible per git
commit (SOURCE_DATE_EPOCH = commit time), so re-pushing the same commit
is an idempotent overwrite. Without git (e.g. files copied to a bare dir)
the build still works but uses a wall-clock timestamp — not reproducible —
and a moving tag (APPEND_GIT_SHORT=false) across commits can still
re-push to a new digest; the self-sourcing scan stays correct either way
because it reads the current build.env.
prescan → build → postscan → ingest
───────────── ───── ────────────────────── ──────────
xray-vuln-… build → syft-sbom-postscan ─┐
xray-sbom-… build.env xray-sbom-postscan │
syft-sbom-… xray-vuln-postscan │
grype-vuln │
grype-db-sync ↓
(prescan = scan UPSTREAM_REF) (postscan = scan sbom-ingest →
IMAGE_DIGEST) configured sinks
Every scan job is single-purpose and parallel within its stage.
Swap script names to swap producers — downstream stages keep working
because they consume canonical ${SBOM_FILE} / ${VULN_SCAN_FILE}
from build.env.
Branch behaviour: feature/MR branches build (no push) and scan
(postscan falls back to UPSTREAM_REF since nothing was pushed), so the
pipeline completes green for the merge gate — but the ingest stage
runs only on the default branch + tags. Only main pushes a real
artifact, so only main ships results to the sinks; feature branches
don't post duplicate upstream events to Splunk/Artifactory.
Change triggers. The pipeline runs only when a watched path
changes — the .build-changes list in .gitlab-ci.yml. That list is
the build context: Dockerfile, .dockerignore, the two cert helpers,
certs/**/*, scripts/**/*, image.env. Anything else your Dockerfile
COPYs has to be added there, or editing it ships nothing. The same
applies to build.sh --project-root <path>, which moves the build
context: the rules stay relative to the repository, so extend them to
cover that path yourself.
cosign-sign is dormant (commented blocks in both CI files). Restore
by uncommenting the job AND the - sign stage entry, then setting
COSIGN_KEY (file-type CI variable, ideally Vault transit / KMS).
trivy-{vuln,sbom}-{pre,post}scan are dormant scaffolds for when
Trivy is re-permitted. Pinned to v0.69.3 (last safe pre-compromise
binary; refuses v0.69.4–v0.69.6 even if the mirror serves them).
Only the default branch (main/master) and tag builds push to
the registry. Feature-branch / MR pipelines build + scan as validation
but skip the push — short-lived branches don't pollute the registry.
A pipeline built from pipelines/container.yml promotes in its publish
job: scripts/publish/promote.sh checks the gate evidence against the
candidate digest, copies by digest, and reads the destination back.
Without that pipeline, promotion is a human copy after dev validation:
# Option A — UI: Browse → <repo>/<image>/<dev-tag> → Copy.
# Option B — CLI (scriptable, same digest):
crane auth login <prod-registry> -u <user> -p <password>
crane copy \
<dev-registry>/<project>/<image>@sha256:<digest-from-build.env> \
<prod-registry>/<project>/<image>:<tag>The dev pipeline keeps build.env as a 1-month artifact —
IMAGE_DIGEST is what to copy. Cosign attestations transfer to the
prod tag because the digest is preserved.
There is no extension surface, no DISTRO selector, no remediate stage. Bespoke per-image work goes in the marked region after the cert stage:
# ═══════════════════════════════════════════════════════════════════
# ▼▼▼ FORK EDITS GO HERE ▼▼▼
# ═══════════════════════════════════════════════════════════════════
RUN apk update && apk upgrade --no-cache
# RUN apt-get update && apt-get -y --only-upgrade upgrade && rm -rf /var/lib/apt/lists/*
# RUN microdnf -y update && microdnf clean all
RUN apk add --no-cache curl jq
COPY config/nginx.conf /etc/nginx/nginx.conf
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost/ || exit 1
# ═══════════════════════════════════════════════════════════════════
# ▲▲▲ END FORK EDITS ▲▲▲
# ═══════════════════════════════════════════════════════════════════For distroless / scratch / busybox bases where OS upgrades don't apply, leave the region empty.
The template emits no USER line. The final stage re-bases
FROM base, so the image runs as whatever user the upstream
configured — a non-root upstream stays non-root, and nothing in the
build can quietly turn it into a root image. The cert sidecar's own
USER root never escapes; it is a separate stage.
A RUN that needs write permissions has to enter root, and then it
has to come back. Say so with one knob, RESTORE_USER=true in
image.env, and uncomment the pair in your edit region:
USER root
RUN apk update && apk upgrade --no-cache
ARG ORIGINAL_USER
USER ${ORIGINAL_USER}With RESTORE_USER=true, build.sh resolves ORIGINAL_USER before
the build:
| Situation | Result |
|---|---|
ORIGINAL_USER set in image.env / env |
used verbatim, no lookup (nginx, 1000:1000 and app:app all pass through byte-for-byte) |
crane config succeeds, config names a user |
that user |
crane config succeeds, config names none |
root, because the upstream genuinely runs as root — the build prints which case it took |
crane config fails, or crane is missing |
the build stops. Root is not a safe guess to return to. Fix registry auth, or set ORIGINAL_USER yourself |
A --dry-run warns instead of stopping, since it produces no image.
Leaving RESTORE_USER unset skips the lookup entirely; setting
ORIGINAL_USER without it does nothing, and says so.
The cert sidecar builds a trust store from the cert builder image
(CERT_BUILDER_IMAGE, alpine by default) plus every *.crt / *.pem
in certs/, and the final stage COPYs it over the upstream's:
/etc/ssl/certs/ca-certificates.crt
/etc/ssl/cert.pem
/usr/local/share/ca-certificates
/etc/pki/ca-trust/source/anchors
The replacement is unconditional. An empty certs/ does not skip
it — the upstream bundle is still replaced, just with no corp CA
added. Alpine's bundle is the Mozilla CA list, so for most images the
swap is equivalent. It is not equivalent for an image whose trust
store was curated or FIPS-restricted: that curation is lost, and such
an image should fork the Dockerfile.
Only the OpenSSL-style bundle is touched. A Java cacerts keystore,
Node's compiled-in roots and Python's certifi carry their own trust
stores and are left exactly as the upstream shipped them, so an image
that reads one of those never sees certs/. Loading a corp CA into
those is per-image work for the fork edits region.
<registry>/<project>/<image>:<UPSTREAM_TAG>-<gitShort>
# e.g. harbor.example.com/apps/platform/nginx:1.25.3-alpine-a1b2c3d
UPSTREAM_TAG— the upstream release this started from (the:taginUPSTREAM_REF). Bumped by Renovate, which reads it from theUPSTREAM_REFURL inimage.env.gitShort— 7-char commit SHA. Every commit gets its own tag, so cert rotation, label tweaks etc. each produce a traceable artifact even when the upstream tag is unchanged.
The OCI org.opencontainers.image.version label is also set to
<UPSTREAM_TAG>-<gitShort> so tools can tell at a glance that this
is a rebuild, not the upstream.
Reproducible digests. The build clock is anchored to the git
commit time via SOURCE_DATE_EPOCH (BuildKit clamps layer + config
timestamps), so a rebuild of the same commit is intended to be
byte-identical and a re-push an idempotent overwrite. Reproducibility
across runners and buildx versions has not been measured here, so treat
it as a design property rather than a guarantee. A postscan job resolves
either way, because it reads IMAGE_DIGEST from the current
build.env. Set SOURCE_DATE_EPOCH explicitly to override.
build.sh adds dynamic labels via docker buildx build --label.
Upstream labels (e.g. maintainer) flow through untouched.
| Label | Source |
|---|---|
org.opencontainers.image.version / .ref.name |
${UPSTREAM_TAG}-${gitShort} |
org.opencontainers.image.revision |
git rev-parse HEAD |
org.opencontainers.image.created |
git commit time of HEAD via SOURCE_DATE_EPOCH (reproducible; override with SOURCE_DATE_EPOCH, falls back to wall-clock only without git) |
org.opencontainers.image.base.name |
UPSTREAM_REF (the full upstream URL) |
org.opencontainers.image.base.digest |
crane digest of the upstream ref |
org.opencontainers.image.source / .url |
CI_PROJECT_URL / git remote |
org.opencontainers.image.vendor |
VENDOR |
org.opencontainers.image.authors |
AUTHORS (default Platform Engineering) |
promoted.from / promoted.tag |
base.name / version |
Every runtime download is variable-driven. Override these to point at internal Artifactory / Nexus mirrors:
| Variable | Default |
|---|---|
DOCKER_CLI_IMAGE / DOCKER_DIND_IMAGE / ALPINE_IMAGE |
Docker Hub library |
UPSTREAM_REF (point the host at your mirror) |
docker.io/library/nginx:<tag> |
CRANE_URL |
github.com/google/go-containerregistry release |
SYFT_INSTALLER_URL / SYFT_VERSION |
raw.githubusercontent.com/anchore/syft |
GRYPE_INSTALLER_URL / GRYPE_VERSION |
raw.githubusercontent.com/anchore/grype |
TRIVY_INSTALLER_URL / TRIVY_BINARY_URL / TRIVY_VERSION |
aquasec install.sh / pinned to v0.69.3 |
JF_BINARY_URL / JF_DEB_URL / JF_RPM_URL |
none — set ONE of these |
CERT_BUILDER_IMAGE |
docker.io/library/alpine:3.20 |
For Grype's CVE database, mirror it once via
./scripts/sync/mirror-grype-db.sh (set ARTIFACTORY_GRYPE_DB_REPO
first) and the grype-vuln job will pull from your mirror automatically.
container-build-template/
├── image.env # ★ Live config. Committed in a per-image repo;
│ # gitignored here, where it is dev scratch. EDIT
├── image.env.example # ★ Slim starter — copy to image.env. NEVER sourced
├── image.env.reference # Full annotated option set (copy extra lines from here)
├── Dockerfile.example # Copy to Dockerfile; also this repo's self-test fixture
├── .dockerignore # Build-context filter — vendored into per-image repos
├── inject-certs.sh # Vendored verbatim into per-image repos
├── install-ca-certificates.sh # Vendored verbatim into per-image repos
├── renovate.json # Tracks UPSTREAM_REF via custom manager
├── certs/ # Gitignored *.crt; populated at build time
├── scripts/ # Template logic — DO NOT EDIT per-fork
│ ├── build.sh # Orchestrator: tags + OCI labels + buildx, dispatches push backend
│ ├── lib/
│ │ ├── load-image-env.sh # image.env loader + bamboo_* importer + _dbg
│ │ ├── artifact-names.sh # Canonical SBOM_FILE / VULN_SCAN_FILE contract
│ │ ├── install-jf.sh # Sudoless jf installer (binary | .deb | .rpm)
│ │ ├── docker-login.sh # Multi-registry login for scan jobs
│ │ ├── splunk-hec.sh # Generic Splunk HEC envelope poster
│ │ ├── bamboo-import.sh # Bamboo plan-var auto-importer (deletable)
│ │ └── build-info-merge.py # Free-tier build-info merger (Pro skips it)
│ ├── push-backends/ # ★ Pick one via REGISTRY_KIND
│ │ ├── harbor.sh # REGISTRY_KIND="harbor"
│ │ ├── artifactory_common.sh # shared by both artifactory tiers
│ │ ├── artifactory_jcr.sh # REGISTRY_KIND="artifactory_jcr" — Free
│ │ └── artifactory_pro.sh # REGISTRY_KIND="artifactory_pro" — Pro
│ ├── scan/ # ★ Pick the producer per stage
│ │ ├── syft-sbom.sh │ xray-sbom.sh │ trivy-sbom.sh (dormant)
│ │ └── grype-vuln.sh │ xray-vuln.sh │ trivy-vuln.sh (dormant)
│ ├── ingest/sbom-post.sh # 5 SBOM sinks
│ ├── sync/mirror-grype-db.sh # Mirror Anchore Grype DB to Artifactory
│ └── test/regression.sh # 50+ scenarios — bash scripts/test/regression.sh
├── pipelines/container.yml # ★ The composition a consumer includes — whole pipeline
├── templates/ # Per-capability component YAML the composition includes
├── runtime/ # CI execution image — every pinned tool, nothing to download
├── docs/standard/ # ★ SKILL.md + REFERENCE.md — the consumer-facing standard
├── examples/consumer/ # ★ The consumer's .gitlab-ci.yml and its CI variables
├── .gitlab-ci.yml # This repo's own pipeline — QA, self-build, runtime image
├── bamboo-specs/bamboo.yml # Bamboo plan spec — same scripts, no composition, no QA stage
├── .gitignore └ LICENSE └ README.md
# 1. First time
cp image.env.example image.env && $EDITOR image.env
# 2. Dry-run (no docker pull, no build)
./scripts/build.sh --dry-run
# 3. Build locally
./scripts/build.sh
# 4. Build + push (creds via env or pre-existing daemon login)
# Harbor backend:
export HARBOR_PASSWORD='...'
./scripts/build.sh --push
# Artifactory backend (image.env: REGISTRY_KIND="artifactory_jcr" or "artifactory_pro"):
export ARTIFACTORY_USER='svc-deploy' ARTIFACTORY_TOKEN='...'
./scripts/build.sh --push
# Verbose:
BUILD_DEBUG=true ./scripts/build.sh --dry-runBefore pushing changes that touch build.sh, scan/*, ingest/sbom-post.sh,
or lib/*:
bash scripts/test/regression.sh # full suite
bash scripts/test/regression.sh registry-kind # filter by name substring50+ scenarios covering: cert sidecar behaviour + CA_CERT materialisation, RESTORE_USER / ORIGINAL_USER resolution, APPEND_GIT_SHORT toggle, required-field validation, argv handling, REGISTRY_KIND backend dispatch, HARBOR_* / ARTIFACTORY_* independence, bamboo_* auto-import, canonical artifact names propagation, scan-target resolution chain, and the silent-fail regression.
MIT — see LICENSE.