← Back to blog
CI/CDSecurityGitLabHarbor

Building a Secure CI Pipeline: From Pre-Commit to Signed Images

· 9 min read

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.