Skip to main content
CleanStart

Knowledge Hub

Building CleanStart Images in GitLab CI

GitLab CI/CD pipeline with environment-based promotion (dev → staging → prod), OIDC token signing, and automated Cosign attestation. The pipeline uses GitLab's native protection rules, audit logging, and environment-specific secrets.

The following diagram illustrates the complete GitLab CI pipeline stages with environment-based promotion:

graph LR    A["Code Push<br/>to main"] -->|Trigger| B["Analyze<br/>Stage"]     B -->|SAST| C["SAST<br/>Scanner"]    B -->|Dependencies| D["Dependency<br/>Scan"]    B -->|Container| E["Container<br/>Scan"]    B -->|SBOM| F["Generate<br/>SBOM"]     C -->|Reports| G["Analyze<br/>Complete"]    D --> G    E --> G    F --> G     G -->|Trigger| H["Build<br/>Stage"]    H -->|Docker Build| I["Hermetic<br/>Image"]    I -->|SBOM Gen| J["Create<br/>Attestation"]    J -->|Store| K["Build Artifacts"]     K -->|Trigger| L["Verify<br/>Stage"]    L -->|SBOM Check| M["Verify<br/>Completeness"]    M -->|FIPS Check| N["Security<br/>Compliance"]    N -->|Cosign Sign| O["Image<br/>Signed"]     O -->|Trigger| P["Deploy Dev"]    P -->|Push| Q["Dev<br/>Registry"]    Q -->|Auto| R["Deploy<br/>Staging"]    R -->|Manual| S["Staging<br/>Registry"]    S -->|Approval| T["Deploy<br/>Prod"]    T -->|Push| U["Prod<br/>Registry"]    U -->|Complete| V["Production<br/>Ready"]

Prerequisites

1. GitLab Project Configuration

Enable the following in your GitLab project settings. Under Settings → General, set Visibility to Private, ensure Issues are Enabled, and enable Merge requests. Under Settings → CI/CD, disable Auto DevOps (we define pipeline explicitly), set Protected branches to main and release/, and protect tags matching v.

Under Settings → Repository, set the default branch to main. Optionally configure Squash commits as Encouraged.

2. Google Cloud Setup

Create workload identity pool for GitLab with:

gcloud iam workload-identity-pools create gitlab \  --location=global \  --display-name="GitLab CI/CD" \  --attribute-mapping="google.subject=assertion.sub,attribute.project_id=assertion.project_id,attribute.namespace_path=assertion.namespace_path"

Create workload identity provider:

gcloud iam workload-identity-pools providers create-oidc gitlab \  --location=global \  --workload-identity-pool=gitlab \  --issuer-uri=https://gitlab.com \  --attribute-mapping="google.subject=assertion.sub"

Create service account:

gcloud iam service-accounts create gitlab-ci \  --display-name="GitLab CI/CD Service Account"

Grant permissions:

gcloud projects add-iam-policy-binding PROJECT_ID \  --member="serviceAccount:gitlab-ci@PROJECT_ID.iam.gserviceaccount.com" \  --role="roles/artifactregistry.writer"

Create workload identity binding:

gcloud iam service-accounts add-iam-policy-binding \  gitlab-ci@PROJECT_ID.iam.gserviceaccount.com \  --role="roles/iam.workloadIdentityUser" \  --member="principalSet://iam.googleapis.com/projects/PROJECT_ID/locations/global/workloadIdentityPools/gitlab/attribute.project_id/PROJECT_ID"

3. GitLab Protected Variables

Go to Settings → CI/CD → Variables and add the following variables: GCP_PROJECT_ID (your Google Cloud project), ARTIFACT_REGISTRY_URL (us-docker.pkg.dev/PROJECT_ID/images), GCP_SERVICE_ACCOUNT (gitlab-ci@PROJECT_ID.iam.gserviceaccount.com), COSIGN_KEY_NAME (kms://projects/PROJECT_ID/locations/us-central1/keyRings/cosign/cryptoKeys/gitlab), DOCKER_DRIVER (overlay2), and DOCKER_TLS_CERTDIR (/certs).

Add environment-specific variables. For dev environment, set DEV_REGISTRY_PATH to us-docker.pkg.dev/PROJECT_ID/dev. For staging environment, set STAGING_REGISTRY_PATH to us-docker.pkg.dev/PROJECT_ID/staging and REQUIRE_APPROVAL to false. For prod environment, set PROD_REGISTRY_PATH to us-docker.pkg.dev/PROJECT_ID/prod, REQUIRE_APPROVAL to true, and RETENTION_DAYS to 2555 (7 years).

Complete Pipeline Configuration

Create .gitlab-ci.yml with the following structure and jobs:

stages:  - analyze  - build  - verify  - deploy-dev  - deploy-staging  - deploy-prod variables:  DOCKER_DRIVER: overlay2  DOCKER_TLS_CERTDIR: "/certs"  FF_USE_FASTZIP: "true"  ARTIFACT_COMPRESSION_LEVEL: fastest # ===== STAGE 1: ANALYZE ===== analyze:sast:  stage: analyze  image: registry.gitlab.com/gitlab-org/security-products/sast:latest  allow_failure: true  script:    - /analyzer run  artifacts:    reports:      sast: gl-sast-report.json    expire_in: 1 week analyze:dependency-scanning:  stage: analyze  image: registry.gitlab.com/gitlab-org/security-products/dependency-scanning:latest  allow_failure: true  script:    - /analyzer run  artifacts:    reports:      dependency_scanning: gl-dependency-scanning-report.json    expire_in: 1 week analyze:container-scanning:  stage: analyze  image: aquasec/trivy:latest  script:    - trivy image --format sarif --output trivy-report.sarif .    - trivy image --format json --output trivy-report.json .  artifacts:    reports:      container_scanning: trivy-report.json    paths:      - trivy-report.sarif    expire_in: 1 week analyze:sbom:  stage: analyze  image: ghcr.io/cyclonedx/cyclonedx-linux/0.24.0:latest  script:    - cyclonedx-linux --help > /dev/null    - apk add --no-cache git    - cyclonedx-linux -o json -output sbom.json . || true    - cyclonedx-linux -o xml -output sbom.xml . || true    - |      cat > sbom.spdx <<EOF      SPDXVersion: SPDX-3.0      DataLicense: CC0-1.0      SPDXID: SPDXRef-DOCUMENT      DocumentName: $(git remote get-url origin | cut -d'/' -f5-)      DocumentNamespace: https://sbom.example.com/$(git rev-parse --short HEAD)      Creator: Tool: cyclonedx-$(date -u +'%Y-%m-%dT%H:%M:%SZ')      Created: $(date -u +'%Y-%m-%dT%H:%M:%SZ')      EOF  artifacts:    paths:      - sbom.*    expire_in: 30 days # ===== STAGE 2: BUILD ===== build:image:  stage: build  image: docker:20.10  services:    - docker:20.10-dind  script:    - export REGISTRY="${DEV_REGISTRY_PATH}"    - export IMAGE_TAG="$(git rev-parse --short HEAD)"    - export IMAGE_NAME="$(basename $CI_PROJECT_PATH | tr '[:upper:]' '[:lower:]')"    - export FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"     # Authenticate with Google Cloud    - echo $GCP_SERVICE_ACCOUNT_KEY | base64 -d > /tmp/gcp-key.json    - |      gcloud auth activate-service-account --key-file=/tmp/gcp-key.json      gcloud config set project $GCP_PROJECT_ID      gcloud auth configure-docker us-docker.pkg.dev     # Build with hermetic constraints    - |      docker build \        --build-arg BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \        --build-arg VCS_REF="${CI_COMMIT_SHA}" \        --build-arg VERSION="${IMAGE_TAG}" \        --tag "${FULL_IMAGE}" \        --tag "${REGISTRY}/${IMAGE_NAME}:latest" \        .     # Run tests in container    - docker run --rm "${FULL_IMAGE}" /test.sh     # Generate SBOM from image    - |      docker run --rm \        -v /tmp:/output \        "${FULL_IMAGE}" \        clnstrt-cli generate-sbom \        --image "${FULL_IMAGE}" \        --output /output/image-sbom.spdx     # Push to dev registry    - docker push "${FULL_IMAGE}"    - docker push "${REGISTRY}/${IMAGE_NAME}:latest"   artifacts:    paths:      - image-sbom.spdx    expire_in: 30 days  only:    - branches # ===== STAGE 3: VERIFY ===== verify:security:  stage: verify  image: alpine:latest  script:    - apk add --no-cache curl jq    - export IMAGE_TAG="$(git rev-parse --short HEAD)"    - export IMAGE_NAME="$(basename $CI_PROJECT_PATH | tr '[:upper:]' '[:lower:]')"     # Verify SBOM completeness    - |      if [[ ! -f "sbom.spdx" ]]; then        echo "❌ SBOM not found"        exit 1      fi      echo "✅ SBOM verified"     # Count components (warning if < 50)    - |      COMPONENTS=$(grep -c "^FileName:" sbom.spdx || echo "0")      if [[ $COMPONENTS -lt 10 ]]; then        echo "⚠️  Warning: Only $COMPONENTS components in SBOM (expected > 10)"      fi     # Check for known vulnerabilities (advisory check)    - |      echo "Checking for high-severity vulnerabilities..."      if grep -q "HIGH\|CRITICAL" sbom.spdx 2>/dev/null; then        echo "⚠️  High/critical severity issues found - proceeding with caution"      else        echo "✅ No critical vulnerabilities found"      fi  dependencies:    - analyze:sbom    - build:image  allow_failure: false verify:fips:  stage: verify  image: alpine:latest  script:    - apk add --no-cache curl openssl     # Check FIPS-approved algorithms in Dockerfile    - |      echo "Checking for FIPS compliance..."      if grep -iE "md5|sha1|des|rc4" Dockerfile; then        echo "❌ Non-FIPS algorithms found in Dockerfile"        exit 1      fi      echo "✅ FIPS algorithms verified"   only:    - main    - tags # ===== STAGE 4: DEV DEPLOYMENT ===== .deploy_template: &deploy_template  image: google/cloud-sdk:alpine  script:    - echo $GCP_SERVICE_ACCOUNT_KEY | base64 -d > /tmp/gcp-key.json    - gcloud auth activate-service-account --key-file=/tmp/gcp-key.json    - gcloud config set project $GCP_PROJECT_ID     - export IMAGE_TAG="$(git rev-parse --short HEAD)"    - export IMAGE_NAME="$(basename $CI_PROJECT_PATH | tr '[:upper:]' '[:lower:]')"    - export FULL_IMAGE="${REGISTRY_PATH}/${IMAGE_NAME}:${IMAGE_TAG}"     # Verify image with Cosign before deployment    - |      gcloud auth configure-docker us-docker.pkg.dev      cosign verify --certificate-identity=$GCP_SERVICE_ACCOUNT \        "${REGISTRY_PATH}/${IMAGE_NAME}:latest" || echo "⚠️  Image signature verification skipped (first push)"     # Trigger deployment (to Kubernetes, Cloud Run, etc.)    - echo "Deploying to $ENVIRONMENT environment..." deploy:dev:  <<: *deploy_template  stage: deploy-dev  environment:    name: dev    kubernetes_namespace: default    auto_stop_in: 1 week  variables:    ENVIRONMENT: dev    REGISTRY_PATH: $DEV_REGISTRY_PATH  script:    - !reference [.deploy_template, script]    - kubectl set image deployment/myapp myapp=${FULL_IMAGE} -n default  only:    - develop # ===== STAGE 5: STAGING DEPLOYMENT ===== deploy:staging:  <<: *deploy_template  stage: deploy-staging  environment:    name: staging    kubernetes_namespace: staging    auto_stop_in: 2 weeks    url: https://staging.example.com  variables:    ENVIRONMENT: staging    REGISTRY_PATH: $STAGING_REGISTRY_PATH  needs:    - verify:security    - verify:fips  script:    - !reference [.deploy_template, script]    - |      # Update image in staging      gcloud run deploy myapp-staging \        --image=${FULL_IMAGE} \        --region=us-central1 \        --platform=managed  only:    - main  when: manual  # Requires explicit approval # ===== STAGE 6: PRODUCTION DEPLOYMENT ===== deploy:prod:  <<: *deploy_template  stage: deploy-prod  environment:    name: prod    kubernetes_namespace: production    auto_stop_in: never    url: https://app.example.com  variables:    ENVIRONMENT: prod    REGISTRY_PATH: $PROD_REGISTRY_PATH  needs:    - verify:security    - verify:fips  script:    - !reference [.deploy_template, script]     # Generate release notes    - |      cat > release-notes.md <<EOF      # Release $(git describe --tags)      Deployed: $(date -u +'%Y-%m-%dT%H:%M:%SZ')      Commit: ${CI_COMMIT_SHA}      Image: ${FULL_IMAGE}      EOF     # Sign and attest for production    - |      echo $COSIGN_KEY | base64 -d > /tmp/cosign.key      cosign sign --key /tmp/cosign.key "${FULL_IMAGE}"      cosign attest --predicate sbom.spdx --key /tmp/cosign.key "${FULL_IMAGE}"      rm -f /tmp/cosign.key     # Deploy to production    - |      gcloud run deploy myapp-prod \        --image=${FULL_IMAGE} \        --region=us-central1 \        --platform=managed \        --min-instances=2 \        --max-instances=100 \        --memory=2Gi \        --cpu=2  artifacts:    paths:      - release-notes.md    expire_in: 7 years  only:    - main    - tags  when: manual  # Requires explicit approval from CODEOWNERS # ===== ROLLBACK ===== rollback:prod:  stage: deploy-prod  image: google/cloud-sdk:alpine  script:    - echo $GCP_SERVICE_ACCOUNT_KEY | base64 -d > /tmp/gcp-key.json    - gcloud auth activate-service-account --key-file=/tmp/gcp-key.json    - gcloud config set project $GCP_PROJECT_ID     # Rollback to previous stable version    - |      PREVIOUS_IMAGE=$(gcloud run services describe myapp-prod \        --region=us-central1 \        --format='value(spec.template.spec.containers[0].image)' \        | head -1)       gcloud run deploy myapp-prod \        --image=${PREVIOUS_IMAGE} \        --region=us-central1 \        --platform=managed   environment:    name: prod    action: rollback  when: manual  only:    - main

Dockerfile for CleanStart

FROM cleanstart-gcc:14-builder AS builderLABEL stage=builder RUN apk add --no-cache \    git \    make \    autoconf \    automake \    libtool COPY . /srcWORKDIR /src RUN ./configure --prefix=/usr/local && \    make -j$(nproc) && \    make test RUN clnstrt-cli generate-sbom \    --output /build-artifacts/sbom.spdx FROM cleanstart-gcc:14-prod # Non-root user with minimal privilegesRUN groupadd -r appuser && \    useradd -r -g appuser appuser # Build arguments for provenanceARG BUILD_DATEARG VCS_REFARG VERSION LABEL org.opencontainers.image.created="$BUILD_DATE" \      org.opencontainers.image.revision="$VCS_REF" \      org.opencontainers.image.version="$VERSION" COPY --from=builder /usr/local /usr/localCOPY --from=builder /build-artifacts /attestations USER appuser HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \  CMD /usr/local/bin/healthcheck || exit 1 ENTRYPOINT ["/usr/local/bin/myapp"]CMD ["--config", "/etc/myapp/config.yaml"]

Protection Rules for Main Branch

Go to Settings → Repository → Protected branches and configure main branch protection. Set Allow force push to No. Configure Require approval before merge to 1. Enable Require all status checks to pass: analyze:sast, analyze:dependency-scanning, verify:security, verify:fips. Enable Require code owner approval if a CODEOWNERS file exists. Enable Dismiss approvals on push.

Environment-Specific Secrets

For production deployments, encrypt the Cosign key. Create the encrypted secret with:

echo "YOUR_COSIGN_KEY_BASE64" | base64 > cosign.key.b64gpg --symmetric --cipher-algo AES256 cosign.key.b64# Commit cosign.key.b64.gpg to repository

Add GPG passphrase to CI/CD protected variable: GPG_PASSPHRASE (stored securely in GitLab).

Decrypt during prod deployment:

- gpg --decrypt --batch --passphrase=$GPG_PASSPHRASE cosign.key.b64.gpg | base64 -d > /tmp/cosign.key

Audit and Compliance

GitLab automatically provides pipeline timeline showing exactly when each stage ran. Approval history shows who approved deployments and when. Audit log in Settings → Audit Events shows all changes. Deployment records in Deployments → Environments shows deployment history by environment.

Access audit logs: Project → Deployments → Environments → [prod] → Deployment history.

Troubleshooting

OIDC Token Exchange Fails

Check GitLab runner configuration and OIDC provider:

# Verify workload identity poolgcloud iam workload-identity-pools describe gitlab --location=global

Image Push Authentication Error

Verify Docker registry authentication:

gcloud auth configure-docker us-docker.pkg.devdocker push us-docker.pkg.dev/PROJECT_ID/dev/myapp:test

Manual Approval Stuck

Check protected variables are set for environment: Go to Deployments → Environments → [prod] → Protected variables. Verify all required secrets exist.