Environment Variables and Secrets Management
Learn to manage configuration and sensitive data securely in GitHub Actions workflows using environment variables, secrets, and best practices.
Every developer has accidentally committed an API key or database password to version control at some point. It's one of those mistakes that makes your stomach drop when you realize what happened - especially if it's a production database password or an AWS key with billing access. The panic of trying to figure out who might have seen it, rotating credentials, and hoping no damage was done is something you never want to experience again.
GitHub Actions workflows need access to sensitive information like deployment credentials, API keys, and database passwords, but they also need to keep this information secure. Understanding how to handle configuration and secrets properly protects your applications, infrastructure, and users while maintaining the flexibility to deploy across different environments.
The Configuration Security Spectrum
Not all configuration is equally sensitive, and understanding these differences helps you choose the right storage mechanism. Public configuration like Node.js versions, build flags, or documentation URLs can be stored as plain environment variables. Semi-private configuration like internal API endpoints or database names might reveal architectural details but won't cause security breaches. Truly sensitive information like passwords, API keys, and certificates must be stored as encrypted secrets.
GitHub Actions provides different mechanisms for each level of sensitivity. Environment variables handle public and semi-private configuration that you don't mind appearing in logs. GitHub Secrets encrypt sensitive information and mask it in logs, preventing accidental exposure.
The key principle is that secrets should never appear in your workflow files, even if your repository is private. Workflow files are often shared, copied to other repositories, or included in documentation, creating opportunities for accidental exposure.
Using Environment Variables Effectively
Environment variables in GitHub Actions work at multiple levels - workflow, job, and step - with step-level variables taking precedence over job-level, which take precedence over workflow-level. Understanding this hierarchy helps you organize configuration logically.
Here's a practical example of environment variable management:
name: Multi-Environment Deployment
# Workflow-level variables available to all jobs
env:
NODE_VERSION: '18'
BUILD_ENVIRONMENT: 'development'
on:
push:
branches: [main, develop]
workflow_dispatch:
inputs:
target_environment:
description: 'Environment to deploy to'
required: true
default: 'staging'
type: choice
options: [staging, production]
jobs:
build:
runs-on: ubuntu-latest
# Job-level variables can override workflow-level ones
env:
BUILD_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'production' || 'development' }}
API_BASE_URL: ${{ github.ref == 'refs/heads/main' && 'https://api.example.com' || 'https://api.staging.example.com' }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Show build configuration
run: |
echo "Node Version: $NODE_VERSION"
echo "Build Environment: $BUILD_ENVIRONMENT"
echo "API Base URL: $API_BASE_URL"
echo "Git Branch: ${{ github.ref_name }}"
- name: Install dependencies
run: npm ci
# Step-level variables have highest precedence
- name: Build application
env:
NODE_ENV: production
BUILD_ID: ${{ github.run_number }}
COMMIT_SHA: ${{ github.sha }}
run: |
echo "Building with environment: $NODE_ENV"
echo "Build ID: $BUILD_ID"
echo "Commit: $COMMIT_SHA"
# Create build-time configuration
cat > src/config.js << EOF
export const config = {
environment: '$BUILD_ENVIRONMENT',
apiBaseUrl: '$API_BASE_URL',
buildId: '$BUILD_ID',
commitSha: '$COMMIT_SHA'
};
EOF
npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-${{ env.BUILD_ENVIRONMENT }}
path: dist/
This example shows how environment variables can be computed dynamically based on branch names, user inputs, and other context information. The configuration becomes part of the built application, enabling environment-specific behavior without hardcoding values.
Secrets Management Done Right
GitHub Secrets provide encrypted storage for sensitive information. Secrets are encrypted at rest and only decrypted when accessed by authorized workflows. They're automatically masked in logs, so even if you accidentally try to print them, they appear as ***
instead of the actual values.
Here's how to use secrets properly in a deployment workflow:
name: Secure Deployment
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Use environment protection
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: production-build
path: dist/
- name: Deploy to AWS
env:
# Access secrets through the secrets context
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# Combine secrets with public configuration
AWS_REGION: us-east-1
S3_BUCKET: my-app-production
run: |
echo "Deploying to AWS S3..."
echo "Region: $AWS_REGION"
echo "Bucket: $S3_BUCKET"
# Configure AWS CLI with credentials
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID"
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY"
aws configure set region "$AWS_REGION"
# Sync files to S3
aws s3 sync dist/ s3://$S3_BUCKET/ --delete
# Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
- name: Update database
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
echo "Running database migrations..."
# Use the database connection securely
# Never echo the DATABASE_URL directly
echo "Database connection configured"
# Run migrations (example)
# npx prisma migrate deploy
- name: Send deployment notification
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
--data "{\"text\":\"✅ Production deployment completed for commit ${{ github.sha }}\"}"
Notice how secrets are accessed through ${{ secrets.SECRET_NAME }}
and combined with public configuration. The workflow never echoes secret values directly, and all sensitive operations are contained within the steps that need them.
Environment Protection and Approval Gates
GitHub Environments provide an additional layer of security for sensitive deployments. You can configure environments to require manual approval, restrict deployment to specific branches, or add time delays before deployments proceed.
# This job uses environment protection
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- name: Deploy to production
run: echo "Deploying to production..."
To set up environment protection:
- Go to your repository's Settings → Environments
- Create a "production" environment
- Add required reviewers who must approve deployments
- Restrict deployments to specific branches (like
main
) - Add secrets that are only available to this environment
This ensures that production deployments can't happen accidentally and provides an audit trail of who approved what deployments.
Dynamic Configuration Based on Context
Sometimes you need to compute configuration values based on workflow context, Git information, or external conditions. Here's how to create dynamic configuration:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Determine deployment configuration
run: |
# Set environment based on branch
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "DEPLOY_ENV=production" >> $GITHUB_ENV
echo "API_URL=https://api.example.com" >> $GITHUB_ENV
echo "DEBUG_MODE=false" >> $GITHUB_ENV
elif [ "${{ github.ref }}" = "refs/heads/develop" ]; then
echo "DEPLOY_ENV=staging" >> $GITHUB_ENV
echo "API_URL=https://api.staging.example.com" >> $GITHUB_ENV
echo "DEBUG_MODE=true" >> $GITHUB_ENV
else
echo "DEPLOY_ENV=review" >> $GITHUB_ENV
echo "API_URL=https://api.review.example.com" >> $GITHUB_ENV
echo "DEBUG_MODE=true" >> $GITHUB_ENV
fi
# Create unique deployment identifier
echo "DEPLOY_ID=${{ github.run_number }}-$(date +%s)" >> $GITHUB_ENV
# Set feature flags based on environment
if [ "$DEPLOY_ENV" = "production" ]; then
echo "FEATURE_NEW_UI=true" >> $GITHUB_ENV
echo "FEATURE_BETA_API=false" >> $GITHUB_ENV
else
echo "FEATURE_NEW_UI=true" >> $GITHUB_ENV
echo "FEATURE_BETA_API=true" >> $GITHUB_ENV
fi
- name: Show computed configuration
run: |
echo "Deploy Environment: $DEPLOY_ENV"
echo "API URL: $API_URL"
echo "Debug Mode: $DEBUG_MODE"
echo "Deploy ID: $DEPLOY_ID"
echo "Feature Flags: NEW_UI=$FEATURE_NEW_UI, BETA_API=$FEATURE_BETA_API"
- name: Deploy with computed configuration
run: |
# Use the dynamically computed values
echo "Deploying to $DEPLOY_ENV environment..."
# Your deployment commands would use these environment variables
The GITHUB_ENV
file allows you to create environment variables during workflow execution that subsequent steps can use. This enables complex configuration logic while keeping the values available throughout the workflow.
Validating Configuration
Configuration errors are a common source of deployment failures. Add validation steps to catch problems early:
- name: Validate required configuration
run: |
# Check required environment variables
REQUIRED_VARS="NODE_VERSION API_URL DEPLOY_ENV"
MISSING_VARS=""
for var in $REQUIRED_VARS; do
if [ -z "${!var}" ]; then
echo "❌ Missing required variable: $var"
MISSING_VARS="$MISSING_VARS $var"
else
echo "✅ $var is configured"
fi
done
if [ -n "$MISSING_VARS" ]; then
echo "Missing required variables:$MISSING_VARS"
exit 1
fi
# Validate configuration values
if [[ ! "$API_URL" =~ ^https:// ]]; then
echo "❌ API_URL must start with https://"
exit 1
fi
if [[ ! "$DEPLOY_ENV" =~ ^(production|staging|development)$ ]]; then
echo "❌ DEPLOY_ENV must be production, staging, or development"
exit 1
fi
echo "✅ All configuration validation passed"
Security Best Practices
Here are essential practices for keeping your secrets secure:
Never Log Secrets:
# Bad - never do this
- name: Debug deployment
run: echo "Database password is ${{ secrets.DB_PASSWORD }}"
# Good - secrets are automatically masked but don't risk it
- name: Debug deployment
run: echo "Database connection configured: ✅"
Use Environment-Specific Secrets:
# Instead of one DATABASE_URL secret, use environment-specific ones
DATABASE_URL: ${{ secrets.DATABASE_URL_STAGING }}
# Or
DATABASE_URL: ${{ secrets[format('DATABASE_URL_{0}', env.DEPLOY_ENV)] }}
Rotate Secrets Regularly:
# Add secret rotation reminders
- name: Check secret age
run: |
echo "⚠️ Remember to rotate secrets regularly"
echo "Last deployment: $(date)"
echo "Consider rotating AWS keys, database passwords, and API tokens"
Limit Secret Scope:
Use GitHub Environments to restrict which workflows can access which secrets. Production secrets should only be available to production deployment workflows.
Troubleshooting Configuration Issues
When configuration doesn't work as expected, here's how to diagnose problems:
- name: Debug configuration (safe values only)
run: |
echo "=== Environment Variables ==="
env | grep -E '^(NODE_|API_|DEPLOY_|GITHUB_)' | sort
echo "=== GitHub Context ==="
echo "Repository: ${{ github.repository }}"
echo "Branch: ${{ github.ref_name }}"
echo "Event: ${{ github.event_name }}"
echo "Actor: ${{ github.actor }}"
echo "=== Computed Values ==="
echo "Build Environment: $BUILD_ENVIRONMENT"
echo "Deploy Environment: $DEPLOY_ENV"
# Check file system for configuration files
echo "=== Configuration Files ==="
if [ -f "src/config.js" ]; then
echo "Config file exists:"
cat src/config.js
else
echo "No config file found"
fi
Common Issues and Solutions:
- Variable not set: Check the scope (workflow/job/step level) and spelling
- Secret access denied: Verify the secret exists and the workflow has permission to access it
- Environment-specific config not working: Check branch names and conditions in your logic
- Masked values in logs: This is normal for secrets - they're working correctly
Understanding configuration and secrets management is crucial for building secure, flexible workflows that work across different environments. In the next section, we'll explore deployment strategies and environment management, showing how to safely deliver your applications using the configuration techniques you've learned.
Found an issue?