Skip to content
Codeloom
CI/CD

Secrets Management in CI/CD Pipelines

Learn how to securely manage secrets in CI/CD pipelines using environment variables, vault integrations, and automated rotation strategies.

·7 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • Identify common secrets management anti-patterns in CI/CD
  • Store and inject secrets securely using platform-native tools
  • Integrate HashiCorp Vault with your CI/CD pipeline
  • Implement secret rotation without pipeline downtime

Prerequisites

  • Basic CI/CD pipeline experience
  • Familiarity with environment variables

Secrets like API keys, database passwords, and TLS certificates are essential for your applications to function. They are also among the most dangerous things in your codebase if mishandled. A single leaked secret can give an attacker full access to your production database, cloud provider, or customer data.

CI/CD pipelines make secrets management particularly tricky because they need access to many sensitive credentials to build, test, and deploy your code. This guide covers how to handle secrets safely at every stage of your pipeline.

The Problem with Secrets in Pipelines

CI/CD pipelines need secrets for many tasks: pulling private container images, connecting to databases for integration tests, deploying to cloud providers, and sending notifications. The challenge is giving pipelines access to these secrets without exposing them in logs, artifacts, or source code.

Common mistakes that lead to secret exposure include hardcoding secrets in pipeline configuration files, printing environment variables in debug output, storing secrets in build artifacts, and committing .env files to version control.

Platform-Native Secrets in GitHub Actions

GitHub Actions provides encrypted secrets at the repository, environment, and organization level. These secrets are masked in logs and only available to workflows that run on the repository.

Setting Up Repository Secrets

# Using the GitHub CLI to set secrets
gh secret set DATABASE_URL --body "postgresql://user:pass@db.example.com:5432/mydb"
gh secret set AWS_ACCESS_KEY_ID --body "AKIAIOSFODNN7EXAMPLE"
gh secret set AWS_SECRET_ACCESS_KEY --body "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

# Set secrets for a specific environment
gh secret set DATABASE_URL --env production \
  --body "postgresql://prod-user:prod-pass@prod-db.example.com:5432/mydb"

Using Secrets in Workflows

name: Deploy Application
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Uses environment-specific secrets
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/myapp:$IMAGE_TAG .
          docker push $ECR_REGISTRY/myapp:$IMAGE_TAG

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster production \
            --service myapp \
            --force-new-deployment

Environment Protection Rules

GitHub environments let you add approval requirements and restrict which branches can deploy:

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging  # No approvals needed
    steps:
      - name: Deploy to staging
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: ./deploy.sh staging

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production  # Requires manual approval
    steps:
      - name: Deploy to production
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: ./deploy.sh production

Integrating HashiCorp Vault

For teams that need centralized secrets management across multiple pipelines and services, HashiCorp Vault is the industry standard. Vault provides dynamic secrets, automatic rotation, and detailed audit logging.

Setting Up Vault for CI/CD

First, configure Vault with the secrets your pipeline needs:

# Enable the KV secrets engine
vault secrets enable -path=cicd kv-v2

# Store application secrets
vault kv put cicd/myapp/production \
  database_url="postgresql://prod-user:prod-pass@db.example.com:5432/mydb" \
  api_key="sk-prod-abc123def456" \
  redis_url="redis://:secret@redis.example.com:6379"

vault kv put cicd/myapp/staging \
  database_url="postgresql://staging-user:staging-pass@staging-db.example.com:5432/mydb" \
  api_key="sk-staging-xyz789" \
  redis_url="redis://:staging-secret@staging-redis.example.com:6379"

Vault Policy for CI/CD

Create a policy that gives your pipeline read-only access to the secrets it needs:

# cicd-policy.hcl
cat <<'EOF' > cicd-policy.hcl
path "cicd/data/myapp/*" {
  capabilities = ["read"]
}

path "cicd/metadata/myapp/*" {
  capabilities = ["read", "list"]
}

# Allow token renewal
path "auth/token/renew-self" {
  capabilities = ["update"]
}
EOF

vault policy write cicd-reader cicd-policy.hcl

JWT Authentication for GitHub Actions

The most secure way to connect GitHub Actions to Vault is using JWT/OIDC authentication, which avoids storing any long-lived Vault tokens:

# Configure JWT auth method in Vault
vault auth enable jwt

vault write auth/jwt/config \
  bound_issuer="https://token.actions.githubusercontent.com" \
  oidc_discovery_url="https://token.actions.githubusercontent.com"

# Create a role for your repository
vault write auth/jwt/role/myapp-deploy \
  role_type="jwt" \
  bound_audiences="https://github.com/my-org" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"my-org/myapp"}' \
  user_claim="repository" \
  policies="cicd-reader" \
  ttl="10m"

GitHub Actions Workflow with Vault

name: Deploy with Vault Secrets
on:
  push:
    branches: [main]

permissions:
  id-token: write  # Required for OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Import secrets from Vault
        id: vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.example.com
          method: jwt
          role: myapp-deploy
          jwtGithubAudience: https://github.com/my-org
          secrets: |
            cicd/data/myapp/production database_url | DATABASE_URL ;
            cicd/data/myapp/production api_key | API_KEY ;
            cicd/data/myapp/production redis_url | REDIS_URL

      - name: Deploy application
        run: |
          # Secrets are available as environment variables
          # DATABASE_URL, API_KEY, REDIS_URL are set automatically
          ./deploy.sh production

      - name: Run smoke tests
        env:
          API_KEY: ${{ steps.vault.outputs.API_KEY }}
        run: |
          curl -H "Authorization: Bearer $API_KEY" \
            https://myapp.example.com/health

Dynamic Secrets with Vault

One of Vault’s most powerful features is dynamic secrets. Instead of storing static credentials, Vault generates short-lived credentials on demand. When they expire, they are automatically revoked.

Database Dynamic Secrets

# Enable the database secrets engine
vault secrets enable database

# Configure PostgreSQL connection
vault write database/config/myapp-db \
  plugin_name=postgresql-database-plugin \
  allowed_roles="myapp-readonly,myapp-readwrite" \
  connection_url="postgresql://{{username}}:{{password}}@db.example.com:5432/mydb" \
  username="vault-admin" \
  password="vault-admin-password"

# Create a role that generates read-only credentials
vault write database/roles/myapp-readonly \
  db_name=myapp-db \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"

Use dynamic secrets in your pipeline:

- name: Get dynamic database credentials
  id: db-creds
  uses: hashicorp/vault-action@v3
  with:
    url: https://vault.example.com
    method: jwt
    role: myapp-deploy
    secrets: |
      database/creds/myapp-readonly username | DB_USERNAME ;
      database/creds/myapp-readonly password | DB_PASSWORD

- name: Run integration tests
  env:
    DATABASE_URL: "postgresql://${{ steps.db-creds.outputs.DB_USERNAME }}:${{ steps.db-creds.outputs.DB_PASSWORD }}@db.example.com:5432/mydb"
  run: npm run test:integration
  # Credentials automatically expire after 1 hour

Preventing Secret Leaks

Even with proper secrets storage, leaks can happen through careless logging or misconfigured pipelines. Here are defensive measures to implement.

Git Pre-Commit Hooks

Use tools like gitleaks to prevent secrets from being committed:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
# Install and run manually
brew install gitleaks
gitleaks detect --source . --verbose

Pipeline Secret Scanning

Add secret scanning as a step in your CI pipeline:

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Run Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Run TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

Masking Secrets in Logs

If you must log output that might contain secrets, use masking:

steps:
  - name: Mask dynamic values
    run: |
      SECRET_VALUE=$(vault read -field=value secret/myapp/api-key)
      echo "::add-mask::$SECRET_VALUE"
      echo "API_KEY=$SECRET_VALUE" >> "$GITHUB_ENV"

Secret Rotation Strategy

Secrets should be rotated regularly. Here is a rotation workflow you can schedule:

name: Rotate Secrets
on:
  schedule:
    - cron: '0 0 1 * *'  # First day of each month
  workflow_dispatch:       # Allow manual trigger

jobs:
  rotate:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Generate new API key
        id: new-key
        run: |
          NEW_KEY=$(openssl rand -hex 32)
          echo "::add-mask::$NEW_KEY"
          echo "key=$NEW_KEY" >> "$GITHUB_OUTPUT"

      - name: Update secret in Vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.example.com
          method: jwt
          role: secret-rotator
          secrets: ""
          exportToken: true

      - name: Write new secret to Vault
        run: |
          vault kv patch cicd/myapp/production \
            api_key="${{ steps.new-key.outputs.key }}"

      - name: Update application config
        run: |
          # Trigger a redeployment to pick up the new secret
          kubectl rollout restart deployment/myapp

Wrapping Up

Secrets management in CI/CD comes down to three principles: never store secrets in code, use the least privilege necessary, and rotate credentials regularly. Start with platform-native secrets like GitHub Actions encrypted secrets. When your team grows and you need centralized management across multiple repositories and environments, move to a dedicated solution like HashiCorp Vault with OIDC authentication. Always add secret scanning to your pipeline to catch accidental leaks before they reach production.