Creating Environment-Specific Deployment Workflows with GitHub Actions
When our team decided to formalize our deployment process by separating staging and production environments, GitHub Actions seemed like the perfect solution. What started as a simple workflow setup turned into a deeper exploration of GitHub Actions' environment-specific features and some interesting challenges along the way.
The Initial Problem: Deployment Inconsistency
Before implementing proper CI/CD, our deployment process was manual and prone to errors:
- Developers often forgot to run tests before deploying
- Configuration differences between environments caused unexpected bugs
- Rollbacks were complicated and time-consuming
- No clear history of who deployed what and when
We needed an automated system that would:
- Run our test suite before any deployment
- Deploy to staging automatically on main branch updates
- Deploy to production only after manual approval
- Use environment-specific configuration values
- Provide clear deployment logs and easy rollbacks
Setting Up GitHub Environments
Our first step was to configure GitHub Environments with the appropriate protection rules:
# In GitHub repository settings:
# 1. Create 'staging' environment:
# - No protection rules
# - Environment variables:
# - API_URL: https://api-staging.example.com
# - DEPLOYMENT_BUCKET: staging-deploys
# 2. Create 'production' environment:
# - Protection rules:
# - Required reviewers: [tech-leads]
# - Wait timer: 10 minutes
# - Environment variables:
# - API_URL: https://api.example.com
# - DEPLOYMENT_BUCKET: production-deploys
# - CLOUDFRONT_ID: XYZ123Creating the Workflow Files
We created two separate workflow files to handle our deployment pipeline:
1. Continuous Integration Workflow (.github/workflows/ci.yml):
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint code
run: npm run lint
- name: Run tests
run: npm test
- name: Build application
run: npm run build
# Store the build for deployment jobs
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: build-artifact
path: .next/2. Environment-Specific Deployment Workflow (.github/workflows/deploy.yml):
name: Deploy
on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches:
- main
jobs:
# Deploy to staging automatically after CI passes
deploy-staging:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Download build artifact
uses: actions/download-artifact@v3
with:
name: build-artifact
path: .next/
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
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: Deploy to S3
run: |
aws s3 sync .next/ s3://${{ env.DEPLOYMENT_BUCKET }}/.next/ --delete
aws s3 sync public/ s3://${{ env.DEPLOYMENT_BUCKET }}/public/ --delete
- name: Notify deployment
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} -H 'Content-Type: application/json' -d '{
"text": "🚀 Staging deployment successful! Version: ${{ github.sha }}"
}'
# Deploy to production only after manual approval
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Download build artifact
uses: actions/download-artifact@v3
with:
name: build-artifact
path: .next/
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
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: Deploy to S3
run: |
aws s3 sync .next/ s3://${{ env.DEPLOYMENT_BUCKET }}/.next/ --delete
aws s3 sync public/ s3://${{ env.DEPLOYMENT_BUCKET }}/public/ --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation --distribution-id ${{ env.CLOUDFRONT_ID }} --paths "/*"
- name: Create deployment tag
run: |
git tag production-$(date +'%Y%m%d-%H%M%S')
git push origin production-$(date +'%Y%m%d-%H%M%S')
- name: Notify deployment
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} -H 'Content-Type: application/json' -d '{
"text": "✅ Production deployment successful! Version: ${{ github.sha }}"
}'Problems We Encountered
Our initial setup had several issues that required troubleshooting:
1. Artifact Access Between Workflows
We quickly discovered that artifacts from one workflow aren't automatically available to another workflow. The solution required implementing a more complex artifact sharing strategy:
# Modified artifact handling in deploy.yml
- name: Download build artifact
uses: dawidd6/action-download-artifact@v2
with:
workflow: ci.yml
workflow_conclusion: success
name: build-artifact
path: .next/2. Environment Variable Scope
We learned that environment variables defined in GitHub Environments aren't automatically exposed to jobs. We needed to explicitly reference them:
# Corrected environment variable usage
- name: Deploy with environment-specific settings
run: |
echo "Deploying to ${{ env.API_URL }}"
npm run deploy -- --api-url=${{ env.API_URL }}3. Conditional Deployment Logic
We needed different behavior based on the environment. We solved this with environment-specific conditions:
# Dynamic deployment step based on environment
- name: Run deployment steps
run: |
if [[ "${{ github.event.deployment.environment }}" == "production" ]]; then
# Production-specific steps
npm run deploy:production
npm run post-deploy:notify-users
else
# Staging-specific steps
npm run deploy:staging
npm run post-deploy:run-tests
fi4. Handling Deployment Failures
We needed a better way to handle and report deployment failures. We implemented a custom error handler:
# Error handling for deployments
- name: Deploy with error handling
id: deploy
run: |
if ! npm run deploy; then
echo "::set-output name=status::failed"
exit 1
else
echo "::set-output name=status::success"
fi
- name: Report deployment failure
if: steps.deploy.outputs.status == 'failed' || failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} -H 'Content-Type: application/json' -d '{
"text": "❌ Deployment to ${{ github.event.deployment.environment }} FAILED! <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|See details>"
}'Adding Emergency Rollback
After a deployment went wrong, we realized we needed a quick rollback mechanism:
name: Emergency Rollback
on:
workflow_dispatch:
inputs:
version:
description: 'Tag to roll back to (e.g., production-20230615-123045)'
required: true
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.version }}
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
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: Deploy previous version
run: |
aws s3 sync .next/ s3://${{ env.DEPLOYMENT_BUCKET }}/.next/ --delete
aws s3 sync public/ s3://${{ env.DEPLOYMENT_BUCKET }}/public/ --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation --distribution-id ${{ env.CLOUDFRONT_ID }} --paths "/*"
- name: Notify rollback
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} -H 'Content-Type: application/json' -d '{
"text": "🔄 EMERGENCY ROLLBACK to ${{ github.event.inputs.version }} completed!"
}'Monitoring Deployments
To track our deployments across environments, we built a custom dashboard using GitHub's API. The script pulls deployment history from both environments:
#!/usr/bin/env node
const fetch = require('node-fetch');
async function fetchDeployments() {
const response = await fetch(
'https://api.github.com/repos/our-org/our-repo/deployments',
{
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
},
}
);
const deployments = await response.json();
// Group by environment
const byEnvironment = deployments.reduce((acc, deployment) => {
const env = deployment.environment;
if (!acc[env]) acc[env] = [];
acc[env].push({
id: deployment.id,
sha: deployment.sha.substring(0, 7),
creator: deployment.creator.login,
created_at: new Date(deployment.created_at).toLocaleString(),
status: deployment.statuses?.sort((a, b) =>
new Date(b.created_at) - new Date(a.created_at)
)[0]?.state || 'unknown'
});
return acc;
}, {});
console.log('=== DEPLOYMENT HISTORY ===');
for (const [env, deploys] of Object.entries(byEnvironment)) {
console.log(`\n${env.toUpperCase()} ENVIRONMENT:`);
console.table(deploys.slice(0, 5)); // Show last 5 deployments
}
}
fetchDeployments().catch(console.error);Lessons Learned
Setting up environment-specific workflows taught us several valuable lessons:
- Start with clear environment definitions: Define what makes each environment unique before writing workflows.
- Test workflows in isolation: Use
workflow_dispatchto trigger individual workflows during development. - Plan for failure: Always build notification and rollback mechanisms from the start.
- Document everything: Create a deployment guide for the team explaining how the workflows function.
- Secret management is critical: Use environment-specific secrets and rotate them regularly.
One unexpected benefit was how this process forced us to standardize our deployment requirements across environments, which improved overall system reliability.
Final Implementation: Enhanced Security
After our initial success, we enhanced security by implementing OpenID Connect (OIDC) for AWS authentication instead of using long-lived access keys:
# Updated AWS credential handling
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-${{ github.event.deployment.environment }}
aws-region: us-east-1This approach eliminated the need to store AWS credentials as GitHub secrets, reducing our security exposure.
Conclusion
GitHub Actions environment-specific workflows have transformed our deployment process from a manual, error-prone task to a reliable, automated system. The initial setup required some troubleshooting, but the benefits far outweighed the effort.
Our team now enjoys:
- Consistent deployments with appropriate approvals
- Environment-specific configuration without code changes
- Clear deployment history and audit trails
- Quick recovery options when issues arise
- Better security through OIDC and environment isolation
For teams considering a similar approach, I recommend starting with a minimal viable pipeline and iteratively adding features like notifications, rollbacks, and enhanced security. This incremental approach allows you to address issues one at a time and build confidence in your deployment system.