Skip to main content
CleanStart

Knowledge Hub

Customizing CleanStart Images in CI/CD Pipelines

Customizing CleanStart Images Through CI/CD: From Source to Signed Artifact

Every organization needs a repeatable, auditable process for customizing CleanStart images and deploying them to production. Manual customization breaks reproducibility, auditability, and compliance — the pipeline ensures all customization is version-controlled, scanned, signed, and traceable to a Git commit.

Part 1: Why CI/CD Customization Matters

The Core Principle

All customization happens through the pipeline, never manually. This is the foundational principle of secure and maintainable container operations. Manual customization—ad-hoc docker commits, manual registry pushes, one-off deployments—breaks every aspect of modern software supply chain security.

Manual customization destroys reproducibility because the same source code produces different images depending on when you build, who built it, and what tools they had locally. If you rebuild the image tomorrow, you might get different results because local dependencies changed, or a base image was updated. This inconsistency makes debugging impossible and prevents reliable rollbacks. Manual customization completely breaks auditability because there's no record of who made changes, when, or why. Changes exist only in a developer's shell history and brain. Supply chain provenance is broken because the image cannot be traced back to a specific Git commit. Security scanning and signing are bypassed entirely because there's no formal pipeline to enforce them.

A proper CI/CD pipeline ensures that every image is built exclusively from version-controlled source code—Dockerfile, application code, configuration—all stored in Git with a complete history. Every build is automatically scanned for vulnerabilities before being released. Every image is cryptographically signed with a verifiable key, enabling downstream systems to reject unsigned or tampered images. Every build produces a complete Software Bill of Materials (SBOM) documenting every package, library, and component in the image. Most importantly, every build is traceable to a specific Git commit, enabling forensics and rollback.

Supply Chain Security Standard: SLSA

SLSA (Supply-chain Levels for Software Artifacts) is an industry standard framework for software supply chain security, created by Google and endorsed by the Linux Foundation. It defines four maturity levels for supply chain security, each building on the previous.

Level 1 requires source code version control. All source code—application code, Dockerfiles, configuration files—must be version-controlled in a Git repository (or equivalent). This enables auditability and rollback. Many organizations stop here, assuming version control is sufficient.

Level 2 adds automated CI/CD builds and cryptographic signing. Builds happen automatically in a CI/CD system (GitHub Actions, GitLab CI, Cloud Build), not on developer machines. The system maintains an audit log of every build. Generated artifacts (container images) are cryptographically signed, enabling verification that the artifact came from the official pipeline.

Level 3 adds provenance verification and hardened builds. The CI/CD system generates cryptographic provenance attestations proving how the image was built, what inputs were used, and what tooling was applied. Provenance is machine-readable and can be cryptographically verified. The build environment is hardened to prevent tampering.

Level 4 requires two-person review and offline signing keys. Any changes to the build process require approval from at least two authorized individuals. Signing keys are kept offline and used only during formal release processes, preventing compromise of actively-used keys.

CleanStart pipelines are designed to meet SLSA Level 3 out of the box, providing provenance verification, automated builds, cryptographic signing, and complete auditability. Organizations can extend to SLSA Level 4 by adding additional approval gates and offline key management.

Part 2: The Secure Customization Pipeline (5 Stages)

Every build goes through five sequential stages, each with specific tasks and validation gates.

Stage 1: Source (Git). The process begins when you commit application code and a Dockerfile to Git. The pipeline is triggered automatically on the commit, ensuring the build captures the exact state of the code.

Stage 2: Verify Base Image. Before customization begins, the pipeline pulls the CleanStart base image and verifies its authenticity. The signature is checked using cosign verify, and vulnerability scan results are reviewed to ensure the foundation is secure.

Stage 3: Customize & Build. The pipeline runs the build using either a multi-stage Dockerfile (for code changes) or cleanimg-customize (for static files). After the image is built, it is scanned with grype to identify vulnerabilities. Any high or critical vulnerabilities cause the build to be rejected.

Stage 4: Generate Provenance & Sign. Once the build passes scanning, the pipeline generates a Software Bill of Materials (SBOM) with syft, creates a provenance attestation for SLSA compliance, and cryptographically signs the image with cosign.

Stage 5: Push & Register. The signed image is pushed to the registry, and the SBOM is attached as an image attestation. Provenance metadata is uploaded, and the deployment system is notified that a new image is ready.

Stage 1: Source (Git)

Your Git repository has a standard structure that supports the CI/CD pipeline. At the root level, place the Dockerfile that defines your multi-stage build process. The .github/workflows/ directory contains the CI/CD pipeline configuration file (build.yml). Application code lives in the src/ directory and can include Python files like main.py and app.py. Configuration files go in the config/ directory, such as cleanimg-init.toml for initialization settings. Static assets and certificates are stored in the certs/ directory, including the corporate CA bundle used by cleanimg-customize.

Stage 2: Verify Base Image

Before customizing a CleanStart image, verify it:

# Pull base imagedocker pull cleanstart/postgresql:15-prod@sha256:abc123... # Verify signaturecosign verify --key https://keys.cleanstart.dev/cosign.pub \  cleanstart/postgresql:15-prod@sha256:abc123... # Check vulnerability scan (from image attestation)grype --show-suppressed cleanstart/postgresql:15-prod@sha256:abc123...

Stage 3: Customize & Build

Run the build (Dockerfile or cleanimg-customize):

# Multi-stage builddocker build -t myregistry/app:latest -f Dockerfile . # Or use cleanimg-customize v0.3.0 (spec-based)cleanimg-customize build \  --spec postgres-spec.yaml \  --tag myregistry/postgres:15-custom # Scan the resultgrype myregistry/app:latest --fail-on high# Exit code 0 = pass, non-zero = fail (reject the build)

Stage 4: Generate Provenance & Sign

Attach metadata and sign:

# Generate SBOM (Software Bill of Materials)syft myregistry/app:latest -o json > sbom.json # Create SLSA provenanceslsa-provenance generate \  --builder-image myregistry/app:latest \  --materials ./Dockerfile \  --environment CI_COMMIT_SHA=$GIT_COMMIT \  > provenance.json # Sign image with cosign (OIDC keyless on GitHub)cosign sign --key cosign.key myregistry/app:latest# Or on GitHub (OIDC):cosign sign --key ghcr.io/cosign/keys myregistry/app:latest # Attach attestationscosign attach attestation --attestation sbom.json \  myregistry/app:latestcosign attach attestation --attestation provenance.json \  myregistry/app:latest

Stage 5: Push & Register

Push the signed image and notify the system:

# Push to registry (with SBOM and provenance attached)docker push myregistry/app:latest # Register in image registry (optional, for governance)curl -X POST https://image-registry/api/images \  -H "Authorization: Bearer $TOKEN" \  -d @- <<EOF{  "image": "myregistry/app:latest",  "git_ref": "$GIT_COMMIT",  "sbom": "$(base64 sbom.json)",  "signed": true,  "vulnerability_scan": "passed"}EOF

Part 3: GitHub Actions — Complete Workflow

This is a production-ready .github/workflows/build.yml file:

name: Build and Sign Image on:  push:    branches: [main, develop]    paths:      - 'Dockerfile'      - 'src/**'      - 'config/**'      - '.github/workflows/build.yml'  pull_request:    branches: [main] env:  REGISTRY: ghcr.io  IMAGE_NAME: ${{ github.repository }} jobs:  build:    runs-on: ubuntu-latest    permissions:      contents: read      packages: write      id-token: write  # For cosign OIDC     steps:      # 1. Setup      - name: Checkout code        uses: actions/checkout@v4        with:          fetch-depth: 0  # Full history for versioning       - name: Set up Docker        uses: docker/setup-buildx-action@v2       # 2. Verify base image (if using CleanStart)      - name: Verify CleanStart Base Image        run: |          docker pull cleanstart/postgresql:15-prod@sha256:abc123...          # Verify signature          cosign verify --key https://keys.cleanstart.dev/cosign.pub \            cleanstart/postgresql:15-prod@sha256:abc123...          # Scan base image          grype cleanstart/postgresql:15-prod@sha256:abc123... \            --fail-on medium || true       # 3. Build image      - name: Build Docker image        uses: docker/build-push-action@v5        with:          context: .          push: false          load: true          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}          outputs: type=docker       # 4. Scan for vulnerabilities      - name: Scan image for vulnerabilities        run: |          # Install grype          curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin           # Scan and fail on high/critical          /usr/local/bin/grype \            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \            --fail-on high \            -o json > scan-results.json       # 5. Generate SBOM      - name: Generate SBOM        run: |          # Install syft          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin           # Generate SBOM in SPDX format          /usr/local/bin/syft \            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \            -o spdx-json > sbom.spdx.json           # Also generate in CycloneDX format          /usr/local/bin/syft \            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \            -o cyclonedx-json > sbom.cyclonedx.json       # 6. Login to registry      - name: Log in to Container Registry        uses: docker/login-action@v2        with:          registry: ${{ env.REGISTRY }}          username: ${{ github.actor }}          password: ${{ secrets.GITHUB_TOKEN }}       # 7. Push image      - name: Push image to registry        uses: docker/build-push-action@v5        with:          context: .          push: true          tags: |            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ github.ref_name }}       # 8. Install cosign      - name: Install cosign        uses: sigstore/cosign-installer@v3        with:          cosign-release: 'v2.2.0'       # 9. Sign image with cosign (OIDC keyless on GitHub)      - name: Sign image with cosign        run: |          # GitHub Actions automatically provides OIDC token          cosign sign --yes \            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.push.outputs.digest }}       # 10. Attach SBOM attestation      - name: Attach SBOM to image        run: |          cosign attach attestation --yes \            --attestation sbom.spdx.json \            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.push.outputs.digest }}       # 11. Create SLSA provenance      - name: Generate SLSA provenance        uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3@v1.9.0        with:          image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}          digest: ${{ steps.push.outputs.digest }}          registry-username: ${{ github.actor }}          registry-password: ${{ secrets.GITHUB_TOKEN }}       # 12. Upload scan results      - name: Upload vulnerability scan results        uses: actions/upload-artifact@v4        if: always()        with:          name: vulnerability-scan          path: scan-results.json       # 13. Upload SBOM      - name: Upload SBOM        uses: actions/upload-artifact@v4        with:          name: sbom          path: |            sbom.spdx.json            sbom.cyclonedx.json       # 14. Create GitHub release (on main branch only)      - name: Create Release        if: github.ref == 'refs/heads/main' && github.event_name == 'push'        uses: softprops/action-gh-release@v1        with:          files: |            sbom.spdx.json            sbom.cyclonedx.json            scan-results.json          tag_name: v${{ github.run_number }}          body: |            Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}            SBOM: sbom.spdx.json            Scan Results: scan-results.json          draft: false          prerelease: false        env:          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}       # 15. Notify deployment system      - name: Notify ArgoCD        if: github.ref == 'refs/heads/main' && github.event_name == 'push'        run: |          curl -X POST https://argocd.company.com/api/webhooks/github \            -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \            -H "Content-Type: application/json" \            -d '{              "image": "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}",              "digest": "${{ steps.push.outputs.digest }}",              "sbom": "sbom.spdx.json"            }'

Key Features of This Workflow

Feature

Benefit

OIDC keyless signing

No secrets stored; uses GitHub's OIDC identity

Vulnerability scanning

Grype scans and fails the build on high/critical

SBOM generation

Two formats (SPDX, CycloneDX) for compliance

SLSA provenance

Cryptographic proof of build chain

Attestation

SBOM attached to image in registry

Multi-arch tags

Supports latest, version, SHA tags

Artifact upload

Scan results and SBOM available for audit

ArgoCD notification

Automatic deployment trigger on main branch

Part 4: GitLab CI — Complete Pipeline

# .gitlab-ci.yml variables:  REGISTRY: registry.gitlab.com  IMAGE_NAME: $CI_PROJECT_PATH  DOCKER_DRIVER: overlay2 stages:  - verify  - build  - scan  - sign  - deploy # Stage 1: Verify base imageverify_base_image:  stage: verify  image: alpine:latest  script:    - apk add --no-cache docker curl    - docker pull cleanstart/postgresql:15-prod@sha256:abc123...    - |      docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \        anchore/grype:latest \        cleanstart/postgresql:15-prod@sha256:abc123... \        --fail-on medium  only:    - main    - develop # Stage 2: Build imagebuild_image:  stage: build  image: docker:latest  services:    - docker:dind  script:    - docker build -t $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA .    - docker tag $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA $REGISTRY/$IMAGE_NAME:latest  artifacts:    reports:      container_scanning: scan-results.json  only:    - main    - develop # Stage 3: Scan for vulnerabilitiesscan_image:  stage: scan  image: anchore/grype:latest  script:    - |      grype \        $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA \        --fail-on high \        -o json > scan-results.json    - grype $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA --fail-on high  artifacts:    paths:      - scan-results.json    expire_in: 30 days  allow_failure: false  only:    - main    - develop # Stage 4: Generate SBOMgenerate_sbom:  stage: scan  image: anchore/syft:latest  script:    - syft $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA -o spdx-json > sbom.spdx.json    - syft $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA -o cyclonedx-json > sbom.cyclonedx.json  artifacts:    paths:      - sbom.*.json    expire_in: 90 days  only:    - main    - develop # Stage 5: Sign image with cosignsign_image:  stage: sign  image: gcr.io/projectsigstore/cosign:latest  script:    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY    - |      cosign sign --key $COSIGN_KEY \        $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA    - |      cosign attach attestation --attestation sbom.spdx.json \        $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA  artifacts:    paths:      - sbom.*.json    expire_in: 90 days  only:    - main  environment:    name: production    kubernetes:      namespace: default # Stage 6: Deploy to productiondeploy_production:  stage: deploy  image: bitnami/kubectl:latest  script:    - kubectl set image deployment/app app=$REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA --record    - kubectl rollout status deployment/app  environment:    name: production    kubernetes:      namespace: production  only:    - main  when: manual

Part 5: Jenkins — Complete Jenkinsfile

// Jenkinsfile @Library('shared-library') _ pipeline {    agent any     options {        buildDiscarder(logRotator(numToKeepStr: '10'))        timeout(time: 30, unit: 'MINUTES')        timestamps()    }     environment {        REGISTRY = credentials('docker-registry-url')        IMAGE_NAME = "${env.BUILD_TAG.toLowerCase()}"        COSIGN_KEY = credentials('cosign-private-key')        COSIGN_PASSWORD = credentials('cosign-password')    }     stages {        stage('Checkout') {            steps {                checkout scm                script {                    env.GIT_COMMIT_SHORT = sh(                        script: "git rev-parse --short HEAD",                        returnStdout: true                    ).trim()                }            }        }         stage('Verify Base Image') {            steps {                script {                    sh '''                        docker pull cleanstart/postgresql:15-prod@sha256:abc123...                        docker run --rm anchore/grype:latest \                            cleanstart/postgresql:15-prod@sha256:abc123... \                            --fail-on medium                    '''                }            }        }         stage('Build Image') {            steps {                script {                    sh '''                        docker build \                            -t ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER} \                            -t ${REGISTRY}/${IMAGE_NAME}:latest \                            .                    '''                }            }        }         stage('Scan for Vulnerabilities') {            steps {                script {                    sh '''                        docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \                            anchore/grype:latest \                            ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER} \                            --fail-on high \                            -o json > scan-results.json                    '''                }                archiveArtifacts artifacts: 'scan-results.json', allowEmptyArchive: false            }        }         stage('Generate SBOM') {            steps {                script {                    sh '''                        docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \                            anchore/syft:latest \                            ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER} \                            -o spdx-json > sbom.spdx.json                         docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \                            anchore/syft:latest \                            ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER} \                            -o cyclonedx-json > sbom.cyclonedx.json                    '''                }                archiveArtifacts artifacts: 'sbom.*.json', allowEmptyArchive: false            }        }         stage('Push Image') {            steps {                script {                    sh '''                        docker push ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}                        docker push ${REGISTRY}/${IMAGE_NAME}:latest                    '''                }            }        }         stage('Sign Image') {            steps {                script {                    sh '''                        export COSIGN_EXPERIMENTAL=1                        cosign sign --key ${COSIGN_KEY} \                            ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}                    '''                }            }        }         stage('Attach Attestation') {            steps {                script {                    sh '''                        cosign attach attestation --attestation sbom.spdx.json \                            ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}                    '''                }            }        }         stage('Deploy to Production') {            when {                branch 'main'            }            steps {                script {                    sh '''                        kubectl set image deployment/app \                            app=${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER} \                            --record                        kubectl rollout status deployment/app                    '''                }            }        }    }     post {        always {            // Clean up            sh 'docker rmi ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER} || true'        }        success {            // Notify            sh '''                curl -X POST https://slack.company.com/api/chat.postMessage \                    -H "Authorization: Bearer ${SLACK_TOKEN}" \                    -d "channel=#devops&text=Image pushed: ${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}"            '''        }        failure {            // Alert            sh '''                curl -X POST https://slack.company.com/api/chat.postMessage \                    -H "Authorization: Bearer ${SLACK_TOKEN}" \                    -d "channel=#devops&text=Build failed: ${BUILD_URL}"            '''        }    }}

Part 6: Google Cloud Build — Complete cloudbuild.yaml

# cloudbuild.yaml steps:  # Step 1: Verify base image  - name: gcr.io/cloud-builders/docker    id: verify-base    entrypoint: bash    args:      - -c      - |        docker pull cleanstart/postgresql:15-prod@sha256:abc123...        # Verify signature (requires cosign)        docker run --rm gcr.io/projectsigstore/cosign:latest \          verify --key https://keys.cleanstart.dev/cosign.pub \          cleanstart/postgresql:15-prod@sha256:abc123...   # Step 2: Build image  - name: gcr.io/cloud-builders/docker    id: build    args:      - build      - -t      - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}      - -t      - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:latest      - .   # Step 3: Scan with Artifact Registry  - name: gcr.io/cloud-builders/container-scanning    id: scan    args:      - scan      - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}   # Step 4: Push to Artifact Registry  - name: gcr.io/cloud-builders/docker    id: push    args:      - push      - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}      - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:latest   # Step 5: Generate SBOM with syft  - name: gcr.io/cloud-builders/container-analysis    id: sbom    args:      - sbom      - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}   # Step 6: Sign image with KMS key  - name: gcr.io/cloud-builders/gke-deploy    id: sign    entrypoint: bash    args:      - -c      - |        docker run --rm \          -v /var/run/docker.sock:/var/run/docker.sock \          gcr.io/projectsigstore/cosign:latest sign \          --key gcpkms://projects/${PROJECT_ID}/locations/${_KMS_REGION}/keyRings/${_KMS_KEYRING}/cryptoKeys/${_KMS_KEY} \          ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}   # Step 7: Deploy to GKE  - name: gcr.io/cloud-builders/gke-deploy    id: deploy    args:      - run      - --filename=k8s/      - --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}      - --location=${_GKE_REGION}      - --cluster=${_GKE_CLUSTER}      - --namespace=production images:  - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:${SHORT_SHA}  - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_IMAGE_NAME}:latest substitutions:  _REGION: us-central1  _REPOSITORY: docker-repo  _IMAGE_NAME: app  _KMS_REGION: us-central1  _KMS_KEYRING: cloudbuild-keys  _KMS_KEY: image-signing-key  _GKE_REGION: us-central1  _GKE_CLUSTER: production-cluster options:  logging: CLOUD_LOGGING_ONLY  machineType: N1_HIGHCPU_8 tags:  - build  - sign  - deploy  - gke

Part 7: Multi-Environment Pipeline

Deploy the same image to different environments with different ConfigMaps:

# .github/workflows/deploy.yml (separate from build.yml) name: Deploy to Environment on:  workflow_dispatch:    inputs:      environment:        description: 'Target environment'        required: true        type: choice        options:          - staging          - production      image_tag:        description: 'Image tag to deploy'        required: true        default: 'latest' jobs:  deploy:    runs-on: ubuntu-latest    environment: ${{ github.event.inputs.environment }}    permissions:      contents: read      packages: read     steps:      - name: Checkout        uses: actions/checkout@v4       - name: Configure kubectl        run: |          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig.yaml          export KUBECONFIG=$(pwd)/kubeconfig.yaml       - name: Deploy to ${{ github.event.inputs.environment }}        run: |          # Verify image signature          cosign verify --key ${{ secrets.COSIGN_PUBLIC_KEY }} \            ghcr.io/${{ github.repository }}:${{ github.event.inputs.image_tag }}           # Deploy with environment-specific ConfigMap          kubectl apply -f k8s/${{ github.event.inputs.environment }}/configmap.yaml          kubectl set image deployment/app \            app=ghcr.io/${{ github.repository }}:${{ github.event.inputs.image_tag }} \            -n ${{ github.event.inputs.environment }}           # Wait for rollout          kubectl rollout status deployment/app -n ${{ github.event.inputs.environment }} --timeout=5m       - name: Verify deployment        run: |          # Health checks          kubectl get pods -n ${{ github.event.inputs.environment }}          kubectl describe deployment app -n ${{ github.event.inputs.environment }}

Part 8: Automated Base Image Updates

When CleanStart releases a new version, automatically rebuild:

# .github/workflows/renovate.json (Renovate Bot config) {  "extends": ["config:base"],  "docker": {    "enabled": true,    "automerge": false,    "major": {      "enabled": false    }  },  "customManagers": [    {      "customType": "dockerfile",      "fileMatch": ["^Dockerfile$"],      "datasourceTemplate": "docker",      "matchStrings": [        "FROM cleanstart/(?<depName>[a-z-]+):(?<currentValue>[0-9.-]+)(?<distroImage>-prod)?"      ],      "versioningTemplate": "semver"    }  ],  "automergeType": "pr",  "automergeStrategy": "squash",  "semanticCommits": "enabled",  "schedule": ["weekly"],  "platformAutomerge": true,  "prCreation": "not-pending"}

When Renovate detects a new CleanStart base image, it creates a PR updating the Dockerfile. It runs the build pipeline (scan, sign, test). If all checks pass, it auto-merges the PR. GitHub Actions deploys the updated image.

Part 9: Pipeline Security Best Practices

1. Pin Image Digests

# WRONG: Uses mutable tagFROM cleanstart/postgresql:15-prod # CORRECT: Uses immutable digestFROM cleanstart/postgresql:15-prod@sha256:abc123def456...

Digests cannot change; tags can be retagged to different images.

2. Verify Signatures Before Building

# In pipeline, before using a base imagecosign verify --key https://keys.cleanstart.dev/cosign.pub \  cleanstart/postgresql:15-prod@sha256:abc123... # If verification fails, the pipeline should stop

3. Never Skip Scanning

# WRONGgrype image:latest || true  # Ignores failures # CORRECTgrype image:latest --fail-on high# Exit code non-zero = pipeline fails

4. Store Signing Keys Securely

Never commit private keys to Git:

# WRONGgit add cosign.keygit commit -m "Add signing key" # CORRECT# Store in GitHub Secrets, Cloud Build KMS, or similar# Reference via ${{ secrets.COSIGN_KEY }}

In GitHub Actions:

- name: Sign image  run: cosign sign --key ${{ secrets.COSIGN_KEY }} image:tag

In Google Cloud Build:

steps:  - name: gcr.io/projectsigstore/cosign    entrypoint: |      cosign sign --key gcpkms://projects/PROJECT_ID/locations/us-central1/keyRings/ring/cryptoKeys/key image

5. Enable Audit Logging

Every pipeline run should be auditable. GitHub Actions automatically logs builds. Jenkins requires enabling and configuring audit plugins. GitLab enables audit event logging. Cloud Build enables Cloud Logging. Query audit logs when needed:

gcloud logging read "resource.type=cloud-build"

6. Require Manual Approval for Production

# GitHub Actionsdeploy_production:  environment:    name: production  # Requires manual approval before running
// Jenkinsinput "Deploy to production?"

Summary: The Production CI/CD Pipeline

Stage

Tool

Gate

Output

Verify base

cosign

Fail if signature invalid

Baseline verified

Build

docker build

Fail if build fails

Image created

Scan

grype

Fail if high/critical

Vulnerability report

Generate

syft

Warn if incomplete

SBOM (SPDX, CycloneDX)

Push

docker push

Fail if push fails

Image in registry

Sign

cosign

Fail if signing fails

Signed image

Attest

cosign

Warn if attestation fails

SBOM attached

Deploy

kubectl

Fail if rollout times out

Live in production

This pipeline ensures every image is built from version-controlled source, every image is scanned and signed, every image has a complete SBOM and provenance, every image is traceable to a Git commit, and every deployment is auditable and reproducible.

This meets SLSA Level 3 requirements and aligns with NIST 800-190, CIS Docker Benchmark, and FedRAMP supply chain security standards.