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: - mainDockerfile 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 repositoryAdd 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.keyAudit 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=globalImage Push Authentication Error
Verify Docker registry authentication:
gcloud auth configure-docker us-docker.pkg.devdocker push us-docker.pkg.dev/PROJECT_ID/dev/myapp:testManual Approval Stuck
Check protected variables are set for environment: Go to Deployments → Environments → [prod] → Protected variables. Verify all required secrets exist.
