Skip to content

About

DevSecOps container build template: pull an upstream image, harden, scan (SBOM+vuln), push to Harbor/Artifactory. GitLab + Bamboo CI. Renovate-tracked upstream.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

container-build-template

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), or artifactory_pro.sh (Pro tier — Pro / JFrog Cloud, slimmer, no python3 dep). Pick via REGISTRY_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.json so downstream stages work regardless of which producer ran.
  • Cert injection: drop *.crt in certs/ or set CA_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) in image.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.yml is the whole pipeline: verify, build, test, scan, attest, publish. See docs/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 — see docs/standard/REFERENCE.md §7.

Consuming the template (per-image repo)

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.yml

The 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/ > .gitignore

certs/ 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.

How the jobs find scripts/

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.

Quick start (self-build)

# 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 --push

image.env.example is the slim starter (the required knob + the common backends). Every other option is documented in image.env.reference.

Required CI variables — secrets only

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.

Running the scripts manually (handover-friendly)

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_image

Drop --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.

Pipeline flow

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).

Branch behaviour

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.

Promoting to production

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.

Editing the Dockerfile

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.

Runtime user

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.

Certificates and the trust store

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.

Tagging convention

<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 :tag in UPSTREAM_REF). Bumped by Renovate, which reads it from the UPSTREAM_REF URL in image.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.

OCI labels

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

Closed-network / air-gap

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.

Repository structure

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

Local build

# 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-run

Local regression testing

Before 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 substring

50+ 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.

License

MIT — see LICENSE.

About

DevSecOps container build template: pull an upstream image, harden, scan (SBOM+vuln), push to Harbor/Artifactory. GitLab + Bamboo CI. Renovate-tracked upstream.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages