Skip to content
Ayhan Sipahi Ayhan Sipahi

Mozilla SOPS: Encrypting Secrets in Git for GitOps

A practical guide to Mozilla SOPS for encrypting secrets in Git: age encryption, AWS CDK and Lambda patterns, and production-ready serverless security.

Mozilla SOPS (Secrets OPerationS) answers one question every GitOps setup runs into: how do you keep secrets in version control without publishing them? SOPS encrypts the values inside YAML and JSON files and leaves the keys readable, so the file structure survives code review while passwords and API keys stay ciphertext. Secrets then travel through the same pull request as the code that reads them.

For serverless teams deploying with AWS CDK, a workable default is age keys in development and AWS KMS in production: encrypt in Git, decrypt during synthesis, inject at deploy time. Credentials that rotate on their own schedule, such as database passwords with automated renewal, still belong in Secrets Manager or Parameter Store.

The GitOps Secret Management Challenge

Working with Infrastructure as Code creates an immediate problem. You need to version-control your serverless configuration files, Terraform variables, and environment configs. But those files contain database passwords, API keys, and service credentials. The moment you commit secrets to Git, you’ve created a security vulnerability.

Traditional solutions create friction. HashiCorp Vault requires running infrastructure and API calls at deployment time. AWS Secrets Manager costs $0.40 per secret per month and adds runtime API calls to your Lambda functions. AWS Systems Manager Parameter Store is free but still requires runtime fetching. Each approach pulls secrets out of your GitOps workflow and into external systems.

Because the encrypted file lives in the repository, changing an API key and updating the Lambda function that reads it is a single commit. A revert takes both back together, and Git history records who changed which secret and when.

Understanding SOPS Architecture

SOPS uses envelope encryption. When you encrypt a file, SOPS generates a random 256-bit data key and encrypts your file content with AES256-GCM. Then it encrypts that data key with one or more master keys (AWS KMS, age, PGP, GCP KMS, or Azure Key Vault) and stores the encrypted data key in the file’s metadata. Age encrypts the SOPS data key using X25519 + ChaCha20-Poly1305.

Plaintext File

Generate Random Data Key

Encrypt Content with AES-256-GCM

Encrypt Data Key with Master Keys

Store Encrypted Content + Metadata

Commit to Git

Pull from Git

Read Encrypted File

Decrypt Data Key with Master Key

Decrypt Content with Data Key

Plaintext Output

For structured formats like YAML and JSON, SOPS only encrypts values, not keys. This keeps your file structure visible for code reviews and allows tools to parse the schema even when values are encrypted.

Here’s what an encrypted YAML file looks like:

database:
  host: ENC[AES256_GCM,data:Zm9vYmFy,iv:abc123,tag:def456,type:str]
  port: ENC[AES256_GCM,data:NTQzMg==,iv:xyz789,tag:uvw012,type:int]
  password: ENC[AES256_GCM,data:c3VwZXI=,iv:secret,tag:hash,type:str]
sops:
  kms:
    - arn: arn:aws:kms:us-east-1:123456789012:key/abc-123
      created_at: '2025-12-17T10:00:00Z'
      enc: AQICAHh...encrypted_data_key...
  age:
    - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBl...
        -----END AGE ENCRYPTED FILE-----
  version: 3.11.0

The structure survives: a host, a port, a password field, with only the values turned into ciphertext. A diff then names the field that changed instead of showing a wall of changed base64.

Installation and Setup

Installation varies by platform but takes less than five minutes:

# macOS with Homebrew
brew install sops

# Linux - download latest release
wget https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
chmod +x sops-v3.11.0.linux.amd64
sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

# Verify installation
sops --version

For container environments, use the official image:

FROM ghcr.io/getsops/sops:v3.11.0

# Copy your encrypted files
COPY secrets.enc.yaml /app/

# Decrypt at runtime
CMD ["sops", "--decrypt", "/app/secrets.enc.yaml"]

Age: The Modern Encryption Choice

SOPS supports multiple key management systems. For team environments, age has become the recommended choice over PGP.

An age public key is 62 characters and a private key is 74, short enough to paste into a Slack message. An armoured PGP public key block runs to thousands of characters and wraps across many lines. Age also has a far smaller surface: X25519 with ChaCha20-Poly1305, and none of the keyring machinery that GPG accumulated over decades.

Generate an age key pair:

# Install age (v1.2.0 or later)
brew install age  # macOS
apt install age  # Ubuntu

# Generate key pair
age-keygen -o ~/.config/sops/age/keys.txt

# Output shows public key
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

The private key is stored in ~/.config/sops/age/keys.txt. The public key is what you share with team members and configure in SOPS.

To encrypt a file with age:

# Set your private key location
export SOPS_AGE_KEY_FILE=$HOME/.config/sops/age/keys.txt

# Encrypt using public key
sops --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
  --encrypt secrets.yaml > secrets.enc.yaml

# Decrypt (uses private key from SOPS_AGE_KEY_FILE)
sops --decrypt secrets.enc.yaml > secrets.yaml

For team distribution, each person generates their own age key and shares their public key. You configure SOPS to encrypt with all team members’ public keys. Anyone with their private key can decrypt.

The .sops.yaml Configuration File

Creating a .sops.yaml file in your repository root eliminates manual key management. SOPS reads this file to determine which keys to use based on file paths.

Here’s a production-ready configuration:

creation_rules:
  # Development - simple age keys for all developers
  - path_regex: \.dev\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1cy0su9fwf8gzkdqh3r4r6xgc92fp8jqrjp4fvd4ak6vd3mc0jjpqnhymkw

  # Staging - AWS KMS for testing production flow
  - path_regex: \.staging\.yaml$
    kms: arn:aws:kms:us-west-2:111111111111:key/staging-key-id
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

  # Production - multiple KMS keys with redundancy
  - path_regex: \.prod\.yaml$
    key_groups:
      - kms:
          - arn: arn:aws:kms:us-east-1:222222222222:key/prod-key-id
            role: arn:aws:iam::222222222222:role/sops-decrypt-role
          - arn: arn:aws:kms:eu-west-1:222222222222:key/prod-key-eu
        age:
          - age1yx3z8r0hnzjy9wh6fq5gldq3p7hxg6nfkz5vgqcdqhsj8tqxj8xq8w6qur

  # Serverless secrets - AWS KMS
  - path_regex: serverless/.*\.yaml$
    kms: arn:aws:kms:us-east-1:222222222222:key/serverless-key-id

  # Terraform variables - for infrastructure
  - path_regex: terraform/.*\.tfvars$
    kms: arn:aws:kms:us-east-1:222222222222:key/terraform-key-id

  # Default fallback for unmatched files
  - age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Now encryption becomes automatic:

# SOPS reads .sops.yaml to find keys
sops --encrypt config.dev.yaml > config.dev.enc.yaml  # Uses age keys
sops --encrypt config.staging.yaml > config.staging.enc.yaml  # Uses AWS KMS
sops --encrypt config.prod.yaml > config.prod.enc.yaml  # Uses KMS + age

The path-based rules eliminate human error. Developers don’t need to remember which keys to use for which environment.

AWS KMS Integration

For production environments, AWS KMS provides centralized key management with IAM-based access control and audit logging through CloudTrail.

Create a KMS key:

# Create KMS key for SOPS
aws kms create-key \
  --description "SOPS encryption key for production" \
  --key-usage ENCRYPT_DECRYPT

# Create alias for easier reference
aws kms create-alias \
  --alias-name alias/sops-production \
  --target-key-id <key-id-from-previous-command>

# Get the key ARN
aws kms describe-key --key-id alias/sops-production

Configure SOPS to use KMS:

export SOPS_KMS_ARN="arn:aws:kms:us-east-1:123456789012:key/abc-123-def"

# Encrypt file
sops --kms $SOPS_KMS_ARN --encrypt secrets.yaml > secrets.enc.yaml

For CI/CD environments, use IAM roles instead of storing credentials:

# GitHub Actions with OIDC
- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v5
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsSOPS
    aws-region: us-east-1

- name: Decrypt with KMS
  run: sops --decrypt secrets.enc.yaml > secrets.yaml

The IAM role needs KMS decrypt permissions:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "kms:Decrypt",
      "kms:DescribeKey"
    ],
    "Resource": "arn:aws:kms:us-east-1:123456789012:key/abc-123"
  }]
}

Multi-Account AWS Setup

Enterprise environments rarely operate within a single AWS account. Development, staging, and production environments run in separate accounts for security isolation and blast radius containment. SOPS supports this architecture through environment-specific KMS keys and cross-account IAM permissions.

Architecture Overview

A typical multi-account setup separates environments into distinct AWS accounts, each with dedicated KMS keys. This prevents developers from accidentally accessing production secrets and provides clear security boundaries.

Assumes Role

Assumes Role

Assumes Role

KMS Key dev-key-id

KMS Key staging-key-id

KMS Key prod-key-id

Assume Dev Role

Assume Staging Role

Assume Prod Role

Developer Workstation

Dev Account 111111111111

Staging Account 222222222222

Prod Account 333333333333

secrets/dev/*.yaml

secrets/staging/*.yaml

secrets/prod/*.yaml

CI/CD Pipeline

This architecture enforces several security principles. Developers need explicit cross-account role assumption to access each environment. KMS keys are account-local, so compromising one environment doesn’t expose others. CloudTrail logs in each account provide independent audit trails.

KMS Keys per Environment Account

Each AWS account maintains its own KMS key. The .sops.yaml configuration maps file paths to account-specific KMS keys.

# .sops.yaml - Multi-account configuration
creation_rules:
  # Development account secrets
  - path_regex: secrets/dev/.*\.yaml$
    kms: arn:aws:kms:eu-central-1:111111111111:key/aaaaaaaa-dev-1111-1111-111111111111

  # Staging account secrets
  - path_regex: secrets/staging/.*\.yaml$
    kms: arn:aws:kms:eu-central-1:222222222222:key/bbbbbbbb-stg-2222-2222-222222222222

  # Production account secrets
  - path_regex: secrets/prod/.*\.yaml$
    kms: arn:aws:kms:eu-central-1:333333333333:key/cccccccc-prd-3333-3333-333333333333

  # Fallback for development with age
  - age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Directory structure mirrors the account separation:

secrets/
├── dev/
│  ├── database.yaml  # Encrypted with dev account KMS
│  └── api-keys.yaml
├── staging/
│  ├── database.yaml  # Encrypted with staging account KMS
│  └── api-keys.yaml
└── prod/
    ├── database.yaml  # Encrypted with prod account KMS
    └── api-keys.yaml

When you encrypt a file in secrets/prod/, SOPS automatically uses the production account KMS key. No manual key selection required.

Cross-Account KMS Access

For CI/CD pipelines to decrypt secrets across accounts, KMS key policies must allow cross-account access. This requires configuration in both the KMS key policy and IAM role permissions.

KMS key policy in the production account (333333333333):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Enable IAM User Permissions",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::333333333333:root"
      },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "Allow CI/CD account to decrypt",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::444444444444:role/GitHubActionsDeployRole"
      },
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": [
            "secretsmanager.eu-central-1.amazonaws.com",
            "lambda.eu-central-1.amazonaws.com"
          ]
        }
      }
    }
  ]
}

The condition restricts KMS usage to specific AWS services, preventing direct key access outside of legitimate deployment contexts.

IAM role in the CI/CD account (444444444444):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": [
        "arn:aws:kms:eu-central-1:111111111111:key/aaaaaaaa-dev-1111-1111-111111111111",
        "arn:aws:kms:eu-central-1:222222222222:key/bbbbbbbb-stg-2222-2222-222222222222",
        "arn:aws:kms:eu-central-1:333333333333:key/cccccccc-prd-3333-3333-333333333333"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": [
        "arn:aws:iam::111111111111:role/LambdaDeployRole",
        "arn:aws:iam::222222222222:role/LambdaDeployRole",
        "arn:aws:iam::333333333333:role/LambdaDeployRole"
      ]
    }
  ]
}

This IAM policy allows the CI/CD role to both decrypt with KMS keys and assume deployment roles in target accounts.

CI/CD with Role Assumption

GitHub Actions workflows assume different roles for different environments. The AWS credentials action supports role chaining for multi-account deployments.

name: Multi-Account Lambda Deploy
on:
  push:
    branches: [main]

jobs:
  deploy-dev:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials for Dev
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::111111111111:role/GitHubActionsDeployRole
          aws-region: eu-central-1

      - name: Install SOPS
        run: |
          wget -q https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
          chmod +x sops-v3.11.0.linux.amd64
          sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

      - name: Decrypt Dev Secrets
        run: sops --decrypt secrets/dev/database.yaml > /tmp/secrets.yaml

      - name: Deploy to Dev Lambda
        run: |
          # Deployment commands use /tmp/secrets.yaml
          serverless deploy --stage dev

  deploy-prod:
    runs-on: ubuntu-latest
    needs: [deploy-dev]
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials for Prod
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::333333333333:role/GitHubActionsDeployRole
          aws-region: eu-central-1

      - name: Install SOPS
        run: |
          wget -q https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
          chmod +x sops-v3.11.0.linux.amd64
          sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

      - name: Decrypt Prod Secrets
        run: sops --decrypt secrets/prod/database.yaml > /tmp/secrets.yaml

      - name: Deploy to Prod Lambda
        run: |
          # Deployment commands use /tmp/secrets.yaml
          serverless deploy --stage prod

The workflow separates deployment jobs by environment. Each job assumes the appropriate account role before decrypting secrets. The needs dependency ensures dev deploys before production. The environment: production adds manual approval gates.

Developer Workflow with AWS Profiles

Developers working locally need AWS profile configurations for each account. The ~/.aws/config file defines role assumption chains.

# ~/.aws/config
[profile dev]
role_arn = arn:aws:iam::111111111111:role/DeveloperRole
source_profile = default
region = eu-central-1
output = json

[profile staging]
role_arn = arn:aws:iam::222222222222:role/DeveloperRole
source_profile = default
region = eu-central-1
output = json

[profile prod]
role_arn = arn:aws:iam::333333333333:role/DeveloperRole
source_profile = default
region = eu-central-1
output = json
mfa_serial = arn:aws:iam::444444444444:mfa/ayhan.sipahi

Production access requires MFA. When a developer runs SOPS commands for production secrets, AWS prompts for an MFA token.

Encrypting secrets for different environments:

# Encrypt dev secret
AWS_PROFILE=dev sops --encrypt secrets/dev/database.yaml > secrets/dev/database.enc.yaml

# Encrypt staging secret
AWS_PROFILE=staging sops --encrypt secrets/staging/database.yaml > secrets/staging/database.enc.yaml

# Encrypt prod secret (prompts for MFA)
AWS_PROFILE=prod sops --encrypt secrets/prod/database.yaml > secrets/prod/database.enc.yaml

Decrypting for local testing:

# Decrypt dev secrets locally
AWS_PROFILE=dev sops --decrypt secrets/dev/database.enc.yaml > .env.dev

# Decrypt staging (with staging role)
AWS_PROFILE=staging sops --decrypt secrets/staging/database.enc.yaml > .env.staging

The profile selection happens through the AWS_PROFILE environment variable. SOPS automatically uses the correct KMS key based on file path and assumes the appropriate role based on the active profile.

Security Considerations

Multi-account SOPS deployments introduce several security requirements that must be enforced through IAM policies and organizational controls.

Principle of Least Privilege: Developers should access only the environments they actively work with. A junior developer working on development environments shouldn’t have production KMS decrypt permissions. Role policies should reflect this segregation.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": [
        "arn:aws:kms:eu-central-1:111111111111:key/aaaaaaaa-dev-1111-1111-111111111111"
      ]
    },
    {
      "Effect": "Deny",
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": [
        "arn:aws:kms:eu-central-1:333333333333:key/cccccccc-prd-3333-3333-333333333333"
      ]
    }
  ]
}

The explicit deny ensures even if higher-level policies grant access, this developer cannot decrypt production secrets.

Audit Logging with CloudTrail: Each AWS account should have CloudTrail enabled with logs shipped to a centralized security account. This creates an immutable audit trail of all KMS operations.

{
  "eventVersion": "1.08",
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "AROAEXAMPLE:ayhan.sipahi",
    "arn": "arn:aws:sts::333333333333:assumed-role/DeveloperRole/ayhan.sipahi"
  },
  "eventTime": "2025-12-17T10:30:00Z",
  "eventSource": "kms.amazonaws.com",
  "eventName": "Decrypt",
  "requestParameters": {
    "keyId": "arn:aws:kms:eu-central-1:333333333333:key/cccccccc-prd-3333-3333-333333333333"
  },
  "responseElements": null,
  "requestID": "abc-123-def-456",
  "resources": [{
    "accountId": "333333333333",
    "type": "AWS::KMS::Key",
    "ARN": "arn:aws:kms:eu-central-1:333333333333:key/cccccccc-prd-3333-3333-333333333333"
  }]
}

CloudTrail logs show who accessed which KMS key at what time. This enables detection of unauthorized access attempts or compliance audits.

Key Policies vs IAM Policies: Use both for defense in depth. KMS key policies define who can use the key at the resource level. IAM policies define what the identity can do. Both must allow the operation for it to succeed.

A production KMS key should have a restrictive key policy that only allows specific deployment roles, even if broader IAM policies exist elsewhere. This prevents privilege escalation through IAM policy changes alone.

Break-Glass Procedures: Even with MFA and restrictive policies, emergencies require rapid production access. Maintain an emergency access role with time-limited credentials and automatic alerting when used.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::333333333333:role/EmergencyBreakGlassRole"
      },
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": "*",
      "Condition": {
        "DateGreaterThan": {
          "aws:CurrentTime": "2025-12-17T00:00:00Z"
        },
        "DateLessThan": {
          "aws:CurrentTime": "2025-12-18T00:00:00Z"
        }
      }
    }
  ]
}

This policy allows emergency access but only within a time window. When the role is assumed, CloudWatch Events trigger alerts to security teams and management.

AWS Lambda Integration with SOPS

Lambda functions need secrets at runtime, but you want to version-control those secrets alongside your function code. SOPS enables this by decrypting secrets during deployment, not at runtime.

Encrypted Secrets

Decrypt with age/KMS

Environment Variables

No Runtime Overhead

Git Repository

CI/CD Pipeline

CDK Deploy

Lambda Function

Application Logic

AWS CDK Integration

AWS CDK provides the most elegant integration with SOPS. CDK can decrypt secrets at synthesis time and inject them directly into Lambda environment variables, SSM parameters, or Secrets Manager. This approach keeps your infrastructure code clean while maintaining GitOps practices.

Create encrypted secrets:

# secrets/prod.enc.yaml
database:
  host: prod-db.example.com
  username: admin
  password: super_secret_password
stripe:
  secret_key: sk_live_abc123
  webhook_secret: whsec_xyz789
redis:
  host: prod-redis.example.com
  port: 6379

Encrypt the file:

sops --encrypt secrets/prod.yaml > secrets/prod.enc.yaml

Pattern 1: Direct Environment Variable Injection

The simplest pattern decrypts SOPS at synthesis time and injects values as Lambda environment variables:

// lib/lambda-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { execSync } from 'child_process';
import * as yaml from 'js-yaml';

export class LambdaStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Decrypt SOPS file at synthesis time
    const decrypted = execSync('sops --decrypt secrets/prod.enc.yaml', {
      encoding: 'utf-8'
    });
    const secrets = yaml.load(decrypted) as any;

    // Create Lambda with decrypted secrets
    new lambda.Function(this, 'ApiFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('src'),
      environment: {
        DB_HOST: secrets.database.host,
        DB_USERNAME: secrets.database.username,
        DB_PASSWORD: secrets.database.password,
        STRIPE_SECRET_KEY: secrets.stripe.secret_key,
        REDIS_HOST: secrets.redis.host,
      },
    });
  }
}

Pattern 2: SSM Parameter Store Population

A more flexible pattern uses SOPS to populate SSM Parameter Store, then references parameters in Lambda:

// lib/lambda-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import { execSync } from 'child_process';
import * as yaml from 'js-yaml';

export class LambdaStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const decrypted = execSync('sops --decrypt secrets/prod.enc.yaml', {
      encoding: 'utf-8'
    });
    const secrets = yaml.load(decrypted) as any;

    // Store secrets in SSM Parameter Store
    const dbPassword = new ssm.StringParameter(this, 'DbPassword', {
      parameterName: '/prod/database/password',
      stringValue: secrets.database.password,
      tier: ssm.ParameterTier.ADVANCED,
      description: 'Production database password',
    });

    const stripeKey = new ssm.StringParameter(this, 'StripeKey', {
      parameterName: '/prod/stripe/secret_key',
      stringValue: secrets.stripe.secret_key,
      tier: ssm.ParameterTier.ADVANCED,
    });

    // Create Lambda referencing SSM parameters
    const fn = new lambda.Function(this, 'ApiFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('src'),
      environment: {
        DB_HOST: secrets.database.host,
        DB_PASSWORD_PARAM: dbPassword.parameterName,
        STRIPE_KEY_PARAM: stripeKey.parameterName,
      },
    });

    // Grant read permissions
    dbPassword.grantRead(fn);
    stripeKey.grantRead(fn);
  }
}

One caveat: CloudFormation cannot create SecureString parameters, so ssm.StringParameter writes the value as a plain String. When the value has to sit encrypted at rest in Parameter Store, create the parameter outside the stack or use a construct that calls the SSM API directly.

Your Lambda code fetches parameters at runtime:

import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

const ssm = new SSMClient({});

async function getSecret(paramName: string): Promise<string> {
  const response = await ssm.send(
    new GetParameterCommand({
      Name: paramName,
      WithDecryption: true,
    })
  );
  return response.Parameter!.Value!;
}

export async function handler() {
  const dbPassword = await getSecret(process.env.DB_PASSWORD_PARAM!);
  const stripeKey = await getSecret(process.env.STRIPE_KEY_PARAM!);
  // Use secrets...
}

Pattern 3: Using cdk-sops-secrets Construct

The cdk-sops-secrets npm package provides a higher-level construct:

npm install cdk-sops-secrets
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { SopsSecret } from 'cdk-sops-secrets';

export class LambdaStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Load SOPS secrets as a CDK construct; the master key comes from the
    // sops metadata inside the file
    const secrets = new SopsSecret(this, 'Secrets', {
      sopsFilePath: 'secrets/prod.enc.yaml',
    });

    const fn = new lambda.Function(this, 'ApiFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('src'),
      environment: {
        DB_HOST: secrets.secretValueFromJson('database.host').unsafeUnwrap(),
        DB_PASSWORD: secrets.secretValueFromJson('database.password').unsafeUnwrap(),
        STRIPE_SECRET_KEY: secrets.secretValueFromJson('stripe.secret_key').unsafeUnwrap(),
      },
    });
  }
}

unsafeUnwrap() emits a {{resolve:secretsmanager:...}} dynamic reference instead of the plaintext, so the synthesized template stays clean. The resolved value is still readable in the Lambda console like any other environment variable.

Pattern 4: Multi-Stack Secret Sharing

For complex applications, share SOPS secrets across multiple stacks:

// lib/secrets-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import { execSync } from 'child_process';
import * as yaml from 'js-yaml';

export class SecretsStack extends cdk.Stack {
  public readonly dbPasswordParam: ssm.IStringParameter;
  public readonly stripeKeyParam: ssm.IStringParameter;

  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const decrypted = execSync('sops --decrypt secrets/prod.enc.yaml', {
      encoding: 'utf-8'
    });
    const secrets = yaml.load(decrypted) as any;

    this.dbPasswordParam = new ssm.StringParameter(this, 'DbPassword', {
      parameterName: '/prod/database/password',
      stringValue: secrets.database.password,
    });

    this.stripeKeyParam = new ssm.StringParameter(this, 'StripeKey', {
      parameterName: '/prod/stripe/secret_key',
      stringValue: secrets.stripe.secret_key,
    });
  }
}

// lib/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';

interface ApiStackProps extends cdk.StackProps {
  dbPasswordParam: cdk.aws_ssm.IStringParameter;
  stripeKeyParam: cdk.aws_ssm.IStringParameter;
}

export class ApiStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: ApiStackProps) {
    super(scope, id, props);

    const fn = new lambda.Function(this, 'ApiFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('src'),
      environment: {
        DB_PASSWORD_PARAM: props.dbPasswordParam.parameterName,
        STRIPE_KEY_PARAM: props.stripeKeyParam.parameterName,
      },
    });

    props.dbPasswordParam.grantRead(fn);
    props.stripeKeyParam.grantRead(fn);
  }
}

// bin/app.ts
import * as cdk from 'aws-cdk-lib';
import { SecretsStack } from '../lib/secrets-stack';
import { ApiStack } from '../lib/api-stack';

const app = new cdk.App();

const secretsStack = new SecretsStack(app, 'SecretsStack');
new ApiStack(app, 'ApiStack', {
  dbPasswordParam: secretsStack.dbPasswordParam,
  stripeKeyParam: secretsStack.stripeKeyParam,
});

Synthesize and deploy:

# CDK decrypts SOPS during synthesis
cdk synth
cdk deploy --all

AWS SAM Integration

For teams using AWS SAM, the function template resolves secrets at deployment time with the resolve:secretsmanager dynamic reference. SOPS owns the encrypted source, Secrets Manager holds the populated values, and SAM injects them as environment variables:

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: python3.12
    Timeout: 30

Resources:
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.handler
      Environment:
        Variables:
          DB_HOST: !Sub '{{resolve:secretsmanager:prod/database:SecretString:host}}'
          DB_PASSWORD: !Sub '{{resolve:secretsmanager:prod/database:SecretString:password}}'

Decrypt the SOPS file and populate Secrets Manager with a JSON secret whose keys match the template references, then deploy:

# SOPS decrypts to JSON; extract the database object so its keys (host, password)
# match the resolve:secretsmanager references in the template
DB_SECRET=$(sops --decrypt --output-type json secrets/prod.enc.yaml | jq -c '.database')

aws secretsmanager create-secret \
  --name prod/database \
  --secret-string "$DB_SECRET"

# Then deploy SAM
sam deploy --stack-name my-api --capabilities CAPABILITY_IAM

Local Development Workflow

For local testing with CDK, decrypt secrets temporarily:

# Decrypt for local development
sops --decrypt secrets/dev.enc.yaml > .env.local

# Run locally with decrypted secrets
npm run dev

# Clean up
rm .env.local

Or use SOPS exec mode to run commands with decrypted environment:

sops exec-env secrets/dev.enc.yaml 'npm run dev'

For CDK synthesis locally, SOPS decrypts automatically:

# CDK synth with SOPS decryption
cdk synth

# Deploy specific stack
cdk deploy ApiStack

SSM Parameter Store vs SOPS Comparison

Use SOPS when:

  • Secrets change with code deployments
  • You want Git-based audit trails
  • Secrets are static (API keys, OAuth credentials)
  • Team collaboration on secrets is important
  • Cost optimization is priority

Use SSM Parameter Store when:

  • Secrets rotate independently of deployments
  • Multiple services share the same secrets
  • You need AWS-native secret rotation
  • Runtime secret updates without redeployment
  • Cross-region secret replication needed

Hybrid approach:

// CDK: Some secrets from SOPS, others from SSM
const sopsSecrets = loadSopsSecrets('secrets/prod.enc.yaml');

new lambda.Function(this, 'ApiFunction', {
  environment: {
    // Static secrets from SOPS
    STRIPE_PUBLIC_KEY: sopsSecrets.stripe.public_key,
    OAUTH_CLIENT_ID: sopsSecrets.oauth.client_id,

    // Dynamic secrets from SSM
    DB_PASSWORD: cdk.aws_ssm.StringParameter.valueForStringParameter(
      this, '/prod/database/password'
    ),
  },
});

Terraform Integration

The Terraform SOPS provider enables reading encrypted variable files while keeping your state file clean.

Configure the provider:

terraform {
  required_providers {
    sops = {
      source  = "carlpett/sops"
      version = "~> 1.3"
    }
  }
}

provider "sops" {}

Create an encrypted variables file:

# secrets.enc.yaml
database:
  username: postgres_admin
  password: super_secret_password
  host: prod-db.example.com
  port: 5432

aws:
  access_key: AKIAIOSFODNN7EXAMPLE
  secret_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

Encrypt it:

sops --encrypt secrets.yaml > secrets.enc.yaml
git add secrets.enc.yaml

Reference in Terraform:

data "sops_file" "secrets" {
  source_file = "secrets.enc.yaml"
}

resource "aws_db_instance" "main" {
  identifier  = "production-db"
  engine  = "postgres"
  instance_class = "db.t3.medium"

  username = data.sops_file.secrets.data["database.username"]
  password = data.sops_file.secrets.data["database.password"]

  lifecycle {
    ignore_changes = [password]
  }
}

output "database_endpoint" {
  value  = aws_db_instance.main.endpoint
  sensitive = true
}

The password never appears in your Terraform state file in plaintext because we use ignore_changes. For initial creation, SOPS decrypts the value. For subsequent applies, Terraform ignores password changes.

A better pattern is using SOPS to populate AWS Secrets Manager, then referencing the secret ARN:

resource "aws_secretsmanager_secret" "db_password" {
  name = "prod/database/password"
}

resource "aws_secretsmanager_secret_version" "db_password" {
  secret_id  = aws_secretsmanager_secret.db_password.id
  secret_string = data.sops_file.secrets.data["database.password"]
}

resource "aws_ecs_task_definition" "app" {
  container_definitions = jsonencode([{
    secrets = [{
      name  = "DB_PASSWORD"
      valueFrom = aws_secretsmanager_secret.db_password.arn
    }]
  }])
}

Now your application runtime fetches secrets from Secrets Manager, but the initial secret values are version-controlled with SOPS.

CI/CD Integration Patterns for AWS CDK

GitHub Actions with CDK

The recommended pattern for CDK deployments with SOPS. CDK automatically decrypts during synthesis:

name: Deploy CDK Lambda with SOPS
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsCDK
          aws-region: us-east-1

      - name: Install SOPS
        run: |
          wget -q https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
          chmod +x sops-v3.11.0.linux.amd64
          sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: CDK Synth (SOPS decrypts during synthesis)
        run: npx cdk synth

      - name: CDK Deploy
        run: npx cdk deploy --all --require-approval never

CDK code decrypts SOPS during synthesis, so no explicit decrypt step needed in CI/CD.

The IAM role needs CDK deployment permissions plus KMS decrypt:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": "arn:aws:kms:us-east-1:123456789012:key/abc-123"
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudformation:*",
        "lambda:*",
        "iam:*",
        "s3:*",
        "ssm:*"
      ],
      "Resource": "*"
    }
  ]
}

Multi-Environment CDK Deployment

Deploy to different environments with environment-specific secrets:

name: Multi-Environment CDK Deploy
on:
  push:
    branches: [main, develop]

jobs:
  deploy-dev:
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::111111111111:role/GitHubActionsCDK
          aws-region: us-east-1

      - name: Install SOPS
        run: |
          wget -q https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
          chmod +x sops-v3.11.0.linux.amd64
          sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: CDK Deploy Dev
        run: npx cdk deploy DevStack --require-approval never

  deploy-prod:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::222222222222:role/GitHubActionsCDK
          aws-region: us-east-1

      - name: Install SOPS
        run: |
          wget -q https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
          chmod +x sops-v3.11.0.linux.amd64
          sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: CDK Deploy Prod
        run: npx cdk deploy ProdStack --require-approval never

GitHub Actions with AWS SAM

For teams using AWS SAM:

name: Deploy SAM with SOPS
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsSAM
          aws-region: us-east-1

      - name: Install SOPS
        run: |
          wget -q https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
          chmod +x sops-v3.11.0.linux.amd64
          sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops

      - name: Setup SAM CLI
        uses: aws-actions/setup-sam@v2

      - name: Decrypt and populate Secrets Manager
        run: |
          sops exec-file secrets/prod.enc.yaml \
            'aws secretsmanager put-secret-value \
              --secret-id prod/app-secrets \
              --secret-string file://{}'

      - name: SAM Build & Deploy
        run: |
          sam build
          sam deploy --stack-name my-lambda-app --no-confirm-changeset

GitLab CI

GitLab CI uses similar patterns:

variables:
  SOPS_VERSION: "3.11.0"

stages:
  - decrypt
  - deploy

decrypt-secrets:
  stage: decrypt
  image: alpine:latest
  before_script:
    - apk add --no-cache wget
    - wget -q https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64
    - chmod +x sops-v${SOPS_VERSION}.linux.amd64
    - mv sops-v${SOPS_VERSION}.linux.amd64 /usr/local/bin/sops
  script:
    - mkdir -p ~/.config/sops/age
    - echo "$SOPS_AGE_KEY" > ~/.config/sops/age/keys.txt
    - chmod 600 ~/.config/sops/age/keys.txt
    - sops --decrypt secrets.enc.yaml > secrets.yaml
  artifacts:
    paths:
      - secrets.yaml
    expire_in: 10 minutes

The decrypted secrets artifact is available to subsequent stages but expires after 10 minutes.

Developer Experience and IDE Integration

Editing encrypted files manually would be painful. SOPS provides an edit mode that handles encryption transparently.

Set your editor:

export EDITOR="code --wait"  # VS Code
# or
export EDITOR="vim"  # Vim

Edit an encrypted file:

sops secrets.enc.yaml

SOPS decrypts the file, opens it in your editor, waits for you to save and close, then re-encrypts with updated values. You never see the encrypted content during editing.

For VS Code, install the SOPS extension:

// .vscode/settings.json
{
  "sops.enable": true,
  "sops.defaults": {
    "age": "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
  },
  "files.associations": {
    "*.enc.yaml": "yaml",
    "*.enc.json": "json"
  }
}

The extension automatically decrypts files when you open them in VS Code and re-encrypts on save.

For meaningful Git diffs, configure a custom differ:

# .gitattributes
*.enc.yaml diff=sopsdiffer
*.enc.json diff=sopsdiffer
# .git/config or ~/.gitconfig
[diff "sopsdiffer"]
  textconv = sops --decrypt

Now git diff secrets.enc.yaml shows the actual value changes, not encrypted blob differences.

Pre-commit Hooks and Validation

Prevent committing decrypted secrets with pre-commit hooks:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/yuvipanda/pre-commit-hook-ensure-sops
    rev: v1.1
    hooks:
      - id: sops-encryption
        files: (secrets|prod).*\.(yaml|json)$

This hook verifies that files matching the pattern are encrypted before allowing the commit.

Add custom validation:

# .git/hooks/pre-commit
#!/bin/bash

# Check for unencrypted sensitive patterns
FORBIDDEN_PATTERNS="password|api_key|secret_token"

for file in $(git diff --cached --name-only); do
  if [[ $file =~ \.(yaml|json)$ ]] && [[ ! $file =~ \.enc\. ]]; then
    if grep -qiE "$FORBIDDEN_PATTERNS" "$file"; then
      echo "ERROR: Possible unencrypted secret in $file"
      echo "Did you mean to commit ${file%.yaml}.enc.yaml?"
      exit 1
    fi
  fi
done

# Verify encrypted files have SOPS metadata
for file in $(git diff --cached --name-only | grep '\.enc\.'); do
  if ! grep -q "^sops:" "$file"; then
    echo "ERROR: $file missing SOPS metadata"
    exit 1
  fi
done

The hook prevents both accidentally committing plaintext secrets and committing files that claim to be encrypted but aren’t.

Key Rotation Strategies

Age keys need a rotation schedule, and the schedule only survives if the steps are scripted. A rotation that depends on someone remembering is the one that gets skipped.

Generate a new age key:

age-keygen -o new-key.txt
OLD_KEY=$(grep "public key:" old-key.txt | cut -d' ' -f3)
NEW_KEY=$(grep "public key:" new-key.txt | cut -d' ' -f3)

Add the new key to all encrypted files:

find . -name "*.enc.yaml" -type f | while read file; do
  sops --add-age "$NEW_KEY" "$file"
done

Rotate the data keys (generates new random keys):

find . -name "*.enc.yaml" -type f | while read file; do
  sops --rotate --in-place "$file"
done

Remove the old key:

find . -name "*.enc.yaml" -type f | while read file; do
  sops --rm-age "$OLD_KEY" "$file"
done

Update .sops.yaml:

sed -i "s/$OLD_KEY/$NEW_KEY/g" .sops.yaml

Commit and distribute the new private key to your team through a secure channel (password manager, encrypted email, secure messaging).

For KMS keys, the process is similar but involves creating a new KMS key, adding it to files, rotating, and removing the old KMS key ARN.

Multi-Team Access with Key Groups

Production environments often require multiple teams to access secrets. SOPS supports this through key groups and Shamir’s Secret Sharing.

# .sops.yaml
keys:
  platform: &platform
    - &platform_admin age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
    - &platform_sre age1cy0su9fwf8gzkdqh3r4r6xgc92fp8jqrjp4fvd4ak6vd3mc0jjpqnhymkw

  security: &security
    - &sec_team age1yx3z8r0hnzjy9wh6fq5gldq3p7hxg6nfkz5vgqcdqhsj8tqxj8xq8w6qur

creation_rules:
  - path_regex: prod/.*\.enc\.yaml$
    key_groups:
      - age:
          - *platform_admin
          - *platform_sre
      - age:
          - *sec_team
    shamir_threshold: 2

With two key groups and shamir_threshold: 2, decryption needs a key from each group, which is separation of duties written as configuration: neither the platform team nor the security team can open a production secret alone. Add a third group, an offline backup key for example, and the same threshold lets any two of the three decrypt.

SOPS splits the data key into one fragment per group using Shamir’s Secret Sharing and encrypts each fragment for the keys in that group. The threshold decides how many fragments have to come back together before the data key can be reconstructed.

Cost Comparison and Trade-offs

For a scenario with 100 secrets:

SOPS with AWS KMS:

  • KMS keys: 3 × $1/month = $3
  • API calls: ~10,000 decryptions/month = $0.03
  • Git storage: $0 (existing repository)
  • Total: $3.03/month

AWS Secrets Manager:

  • Secrets: 100 × $0.40/month = $40
  • API calls: 10,000/month × $0.05/10k = $0.05
  • Total: $40.05/month

Savings: $37/month (92% reduction)

But cost isn’t the only consideration. Secrets Manager provides automated rotation, while SOPS requires scripting. Secrets Manager has built-in audit logs through CloudTrail. SOPS relies on Git history.

SOPS wins for static secrets (API keys, OAuth credentials, database connection strings that change infrequently). Secrets Manager wins for dynamic secrets (database passwords that rotate weekly, service credentials with automated renewal).

A hybrid approach works well:

  • Development and staging: SOPS with age keys
  • Production static secrets: SOPS with KMS
  • Production dynamic secrets: AWS Secrets Manager
  • Database root passwords: Secrets Manager
  • Third-party API keys: SOPS

Common Pitfalls and Solutions

Pitfall: Committing Decrypted Files

Add to .gitignore:

secrets.yaml
config/production.yaml
*.decrypted.yaml

# Allow encrypted files
!*.enc.yaml

Pitfall: Losing Age Private Keys

Store backups in multiple locations:

  • Password manager (1Password, LastPass)
  • Encrypted USB drive in physical safe
  • Emergency recovery key stored offline

Create an emergency key and add it to all production secrets:

age-keygen -o emergency-key.txt
AGE_PUBLIC_KEY=$(grep "public key:" emergency-key.txt | cut -d' ' -f3)

find prod/ -name "*.enc.yaml" | while read file; do
  sops --add-age "$AGE_PUBLIC_KEY" "$file"
done

Pitfall: KMS Permission Issues

The error “AccessDeniedException” when decrypting usually means IAM permissions are wrong. Verify:

aws sts get-caller-identity  # Confirm assumed role
aws kms describe-key --key-id $KMS_KEY_ID  # Test KMS access
sops --decrypt --verbose secrets.enc.yaml  # See detailed error

Ensure your IAM role has both kms:Decrypt and kms:DescribeKey permissions for the KMS key.

Pitfall: Git Merge Conflicts

When two developers edit the same encrypted file simultaneously, Git creates a merge conflict with encrypted blobs.

Note

For normal editing, sops secrets.enc.yaml decrypts the file, opens it in your editor, and automatically re-encrypts when you save and exit. But for merge conflicts, you need to compare two different versions, so the manual decrypt → merge → re-encrypt workflow is required.

Resolving requires:

# Decrypt both versions
sops --decrypt secrets.enc.yaml > mine.yaml
git show origin/main:secrets.enc.yaml | sops --decrypt /dev/stdin > theirs.yaml

# Merge manually
vimdiff mine.yaml theirs.yaml

# Save merged version
mv merged.yaml secrets.yaml

# Re-encrypt
sops --encrypt secrets.yaml > secrets.enc.yaml
git add secrets.enc.yaml

Better: communicate when editing shared secrets, or split large files into smaller domain-specific files to reduce collision probability.

When SOPS Is the Right Default

SOPS holds up when secrets change with the code that consumes them: API keys, OAuth credentials, third-party tokens, per-environment configuration. Encrypt with age in development and KMS in production, let .sops.yaml pick the key by path, and decrypt during CDK synthesis so the function pays nothing at runtime for reading them. On production files, pairing a KMS key with an age recipient gives you centralized IAM control and a recovery path for the day KMS is unreachable.

Override the default when secrets rotate independently of deploys, when several services read the same value at runtime, or when you need managed rotation and cross-region replication. Those belong in Secrets Manager or Parameter Store, and the two models mix without conflict: SOPS for static configuration, an AWS store for anything on a rotation schedule.

Whichever side of that line you start on, add the pre-commit hook before the first encrypted file lands. Without an automated check, a plaintext file eventually gets committed.

References

Related posts