Building a Secure CI Pipeline: From Pre-Commit to Signed Images
A CI pipeline that only builds and pushes is half a pipeline. The other half — the half that prevents vulnerable code from reaching production — is where most teams cut corners. They add a linter, maybe a scan, and call it done.
This post walks through a complete, production-grade GitLab CI pipeline that covers every stage from the developer’s machine to a signed image in a trusted registry. Each layer exists because no single check catches everything. That is defense in depth applied to CI/CD.
If you have been following our series, this pulls together patterns from our Kaniko image building post, the image signing with Cosign and Kyverno walkthrough, and our Vault secrets management guide. Here we tie them into one pipeline.
graph LR
A[Pre-Commit] --> B[Lint]
B --> C[Scan Code]
C --> D[Build - Kaniko]
D --> E[Scan Image]
E --> F[Push - Harbor]
F --> G[Sign - Cosign]
style A fill:#4DB8A4
style G fill:#4DB8A4
Stage 1: Pre-Commit Hooks
The cheapest place to catch a problem is before it enters the repository. Pre-commit hooks run on the developer’s machine at git commit time. They catch formatting issues, linting failures, and — critically — leaked secrets before they ever hit a remote branch.
Here is a .pre-commit-config.yaml that covers the essentials for an infrastructure-heavy codebase:
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.9.0
hooks:
- id: shellcheck
- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint-docker
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: [--baseline, .secrets.baseline]
- repo: https://github.com/zricethezav/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.92.1
hooks:
- id: terraform_fmt
- id: terraform_validate
- repo: https://github.com/ansible/ansible-lint
rev: v24.7.0
hooks:
- id: ansible-lint
This catches shell script errors (shellcheck), malformed YAML (yamllint), Dockerfile anti-patterns (hadolint), hardcoded secrets (detect-secrets, gitleaks), unformatted Terraform (terraform_fmt), and Ansible playbook issues (ansible-lint). Two secret scanners are not redundant — detect-secrets uses entropy analysis and regex patterns while gitleaks focuses on known credential formats. They catch different things.
Install it with pre-commit install and every commit runs these checks automatically.
Stage 2: Linting in CI
Pre-commit hooks are a first line of defense, but they can be bypassed. A developer can run git commit --no-verify, or they might not have pre-commit installed at all. The CI pipeline must enforce the same checks as a hard gate.
lint:
stage: lint
image: registry.example.com/ci-tools/lint:latest
script:
- yamllint --strict .
- find . -name "*.sh" -exec shellcheck {} +
- find . -name "Dockerfile*" -exec hadolint {} +
- terraform fmt -check -recursive
- ansible-lint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Bundle these tools into a dedicated CI image so the job starts fast. The key principle: anything checked locally in pre-commit must also be checked in CI. No exceptions.
Stage 3: Trivy Code Scanning
Linting catches style and syntax issues. Trivy catches security issues — vulnerable dependencies, IaC misconfigurations, and secrets that slipped through.
scan-code:
stage: scan-code
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy fs --exit-code 1 --severity CRITICAL,HIGH --scanners vuln,secret .
- trivy config --exit-code 1 --severity CRITICAL,HIGH .
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Two commands, two scopes. trivy fs scans the filesystem for vulnerable dependencies (package-lock.json, requirements.txt, go.sum, etc.) and embedded secrets. trivy config scans IaC files — Terraform plans, Kubernetes manifests, Dockerfiles — for misconfigurations like overly permissive security contexts or S3 buckets without encryption.
The --exit-code 1 flag fails the pipeline on CRITICAL or HIGH findings. Medium and lower findings get logged but do not block. Adjust the threshold to your risk tolerance, but never set it to CRITICAL-only in the beginning — HIGH vulnerabilities with public exploits are just as dangerous.
Stage 4: Kaniko Build
Once the code passes linting and security scanning, we build the container image. Kaniko builds OCI images without a Docker daemon, eliminating the need for privileged CI runners. We covered this in depth in our Kaniko guide — here is the CI job:
build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
--tar-path image.tar
--no-push
artifacts:
paths:
- image.tar
expire_in: 1 hour
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Note the --no-push and --tar-path flags. We build the image and export it as a tarball artifact. We do not push yet — the image has to pass vulnerability scanning first.
Stage 5: Trivy Image Scan
The built image gets scanned before it goes anywhere near a registry. This catches vulnerabilities in base images, OS packages, and application libraries that were not visible at the code scanning stage.
scan-image:
stage: scan-image
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image
--exit-code 1
--severity CRITICAL,HIGH
--input image.tar
dependencies:
- build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The --input flag reads the tarball artifact from the build stage. No registry pull required. If the image contains a CRITICAL or HIGH CVE, the pipeline stops here. The image never gets pushed.
This is where defense in depth pays off. Code scanning might not flag a vulnerable system library in your base image. Image scanning catches it. Code scanning might catch a vulnerable npm package before it is even built into an image. Different layers, different coverage.
Stage 6: Push to Harbor
Images that pass scanning get pushed to Harbor — not the GitLab Container Registry.
push:
stage: push
image:
name: gcr.io/go-containerregistry/crane:debug
entrypoint: [""]
script:
- crane auth login -u ${HARBOR_USER} -p ${HARBOR_PASSWORD} harbor.example.com
- crane push image.tar harbor.example.com/production/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}
- crane push image.tar harbor.example.com/production/${CI_PROJECT_NAME}:latest
dependencies:
- build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Why Harbor instead of the built-in GitLab registry? Several reasons:
- Built-in vulnerability scanning — Harbor runs its own Trivy scan on push, giving you a second independent gate. If your CI scan missed something (outdated database, configuration drift), Harbor catches it.
- Image replication — replicate images across regions or to disaster recovery sites automatically.
- RBAC — granular access control per project, with robot accounts for CI and read-only accounts for pull.
- Content trust — native Cosign/Notary support with signature verification policies.
- Garbage collection — automated cleanup of untagged images and old tags based on retention policies.
The GitLab Container Registry is fine for development. For production workloads where you need trust boundaries, auditability, and multi-site distribution, Harbor is the right choice.
Store Harbor credentials in Vault and inject them via GitLab CI’s Vault integration. Never put registry passwords in CI/CD variables directly.
Stage 7: Cosign Signing
The final stage signs the image with Cosign, creating a cryptographic proof that it was produced by your pipeline. Our image signing and Kyverno post covers the full setup including Kubernetes admission enforcement — here is the CI job:
sign:
stage: sign
image: bitnami/cosign:latest
variables:
IMAGE: harbor.example.com/production/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}
script:
- cosign sign --key ${COSIGN_PRIVATE_KEY} --yes ${IMAGE}
dependencies: []
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The private key should come from Vault, not from a CI variable. After signing, the Kyverno ClusterPolicy in your Kubernetes cluster will verify the signature at admission time and reject anything unsigned.
The Complete Pipeline
Here is the full .gitlab-ci.yml tying everything together:
stages:
- lint
- scan-code
- build
- scan-image
- push
- sign
variables:
HARBOR_REGISTRY: harbor.example.com
IMAGE_NAME: ${HARBOR_REGISTRY}/production/${CI_PROJECT_NAME}
IMAGE_TAG: ${CI_COMMIT_SHORT_SHA}
lint:
stage: lint
image: registry.example.com/ci-tools/lint:latest
script:
- yamllint --strict .
- find . -name "*.sh" -exec shellcheck {} +
- find . -name "Dockerfile*" -exec hadolint {} +
- terraform fmt -check -recursive
- ansible-lint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
scan-code:
stage: scan-code
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy fs --exit-code 1 --severity CRITICAL,HIGH --scanners vuln,secret .
- trivy config --exit-code 1 --severity CRITICAL,HIGH .
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${IMAGE_NAME}:${IMAGE_TAG}"
--tar-path image.tar
--no-push
artifacts:
paths:
- image.tar
expire_in: 1 hour
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
scan-image:
stage: scan-image
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image --exit-code 1 --severity CRITICAL,HIGH --input image.tar
dependencies:
- build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
push:
stage: push
image:
name: gcr.io/go-containerregistry/crane:debug
entrypoint: [""]
script:
- crane auth login -u ${HARBOR_USER} -p ${HARBOR_PASSWORD} ${HARBOR_REGISTRY}
- crane push image.tar ${IMAGE_NAME}:${IMAGE_TAG}
- crane push image.tar ${IMAGE_NAME}:latest
dependencies:
- build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
sign:
stage: sign
image: bitnami/cosign:latest
script:
- cosign sign --key ${COSIGN_PRIVATE_KEY} --yes ${IMAGE_NAME}:${IMAGE_TAG}
dependencies: []
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Six stages, each one a gate. Code that fails linting never reaches security scanning. Code with vulnerabilities never gets built. Images with CVEs never get pushed. Images without signatures never get deployed (assuming Kyverno enforcement from our signing post).
Why Every Layer Matters
It is tempting to think that one good scanner is enough. It is not. Here is what each layer uniquely catches:
- Pre-commit hooks catch secrets and formatting issues before they enter git history (where they are hard to remove).
- CI linting enforces the same standards for developers who skip pre-commit.
- Trivy code scanning finds vulnerable dependencies and IaC misconfigurations that linters do not cover.
- Trivy image scanning finds OS-level vulnerabilities in base images that code scanning cannot see.
- Harbor scanning provides an independent second opinion with its own vulnerability database update cycle.
- Cosign signing proves provenance — that the image was produced by your pipeline and not tampered with after the fact.
Remove any one layer and you have a gap. Keep all of them and you have a pipeline that an auditor will appreciate and an attacker will find much harder to subvert.
Next Steps
This pipeline is a solid foundation. To extend it further, look at adding SBOM generation with trivy image --format spdx-json, attestation with Cosign’s attest command, and policy-as-code for your pipeline configuration itself.
If you want help implementing this pipeline or hardening your existing CI/CD setup, reach out to us. This is exactly the kind of infrastructure work we do at robto.