Pipelines that build and test a single environment are easy. The hard part is moving the same change through dev, staging, and production with the right gates between each.
Environments
In GitHub Actions, an environment is a named target — dev, staging, production — with its own:
- Secrets (e.g. different cloud creds per environment)
- Variables (e.g. different API URLs)
- Required reviewers (manual approval)
- Wait timer (delay before deploy)
- Branch restrictions (only main can deploy to prod)
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./deploy.sh
env:
API_URL: ${{ vars.API_URL }}
API_KEY: ${{ secrets.API_KEY }}
deploy-prod:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.sh
When the workflow reaches a job with environment: production, GitHub pauses and shows reviewers a "Review deployments" button. Approval starts the job; rejection cancels it.
Branch Strategies
Trunk-Based Development
One long-lived branch (main). All work happens on short-lived feature branches that merge back within a day or two. Releases come from main directly or from short-lived release branches.
- ✅ Continuous integration in the truest sense
- ✅ No merge nightmares
- ✅ Pairs perfectly with feature flags — ship dark code safely
- Used by Google, Facebook, and most modern SaaS
GitHub Flow
A simple variant: feature branches → PR → merge to main → deploy. The default for many startups.
Git Flow
Long-lived develop, main, release/*, and hotfix/* branches. Designed for shrink-wrapped, versioned software with multiple supported releases. Overkill for most web apps; still common in libraries and on-prem products.
Release Branches
Cut release/1.42 from main when you decide to ship 1.42. Bug fixes go to both main and the release branch. Production deploys come from release branches. Useful when you can't always ship straight from main.
Promotion Patterns
Single artefact, many environments
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- run: docker build -t ghcr.io/org/app:${{ steps.meta.outputs.tag }} .
- run: docker push ghcr.io/org/app:${{ steps.meta.outputs.tag }}
outputs:
tag: ${{ steps.meta.outputs.tag }}
deploy-dev:
needs: build
environment: dev
runs-on: ubuntu-latest
steps:
- run: helm upgrade app ./chart --set image.tag=${{ needs.build.outputs.tag }}
deploy-staging:
needs: deploy-dev
environment: staging
runs-on: ubuntu-latest
steps:
- run: helm upgrade app ./chart --set image.tag=${{ needs.build.outputs.tag }}
deploy-prod:
needs: deploy-staging
environment: production
runs-on: ubuntu-latest
steps:
- run: helm upgrade app ./chart --set image.tag=${{ needs.build.outputs.tag }}
Same image tag flows through all three environments. The only difference is the environment's secrets and config.
When to Require Manual Approval
Manual approvals add friction. Use them where the cost of a mistake is high:
- ✅ Production deploys
- ✅ Database migrations or other irreversible operations
- ✅ Customer-impacting feature flag flips
- ❌ Every dev or staging deploy — kills flow
Mature teams aim to remove approvals as confidence in tests and rollback grows. Approvals are a band-aid for missing automation.
Concurrency Control
Two deploys racing each other can leave production in an inconsistent state. Lock them with concurrency:
concurrency:
group: deploy-prod
cancel-in-progress: false
Now only one job in the deploy-prod group can run at a time; new ones queue. Use cancel-in-progress: true for builds where you only care about the latest commit (e.g. PR builds).
Environment Variables and Secrets
| Scope | Used for |
|---|---|
| Repo secrets | Things common to all environments |
| Environment secrets | Per-environment credentials (different DB password per env) |
| Org secrets | Shared across many repos (NPM token, registry creds) |
| Repo / env variables | Non-sensitive config (API URLs, region names) |
Feature Flags Beat Long-Lived Branches
To merge half-finished features without breaking production, ship the code dark and gate it behind a feature flag:
if (flags.isEnabled('new-checkout', { userId })) {
return <NewCheckout />;
}
return <OldCheckout />;
Now the code is in main, deploys daily, and you control rollout independently of releases. Tools: LaunchDarkly, Unleash, ConfigCat, Flagsmith, or your own DB-backed flag service.
Deployment Cadence
The DORA elite-performer benchmark is "deploy on demand, multiple times a day". Reaching it requires:
- Trunk-based development
- Feature flags
- A <15-minute pipeline
- Tests engineers actually trust
- Progressive delivery (canary, blue/green) so each deploy risks little
- Fast, automated rollback
Each piece reinforces the others. Fix the slowest link first.
What's Next
Next: how to handle secrets and supply-chain security inside pipelines — OIDC for cloud creds, dependency pinning, and SLSA-style provenance.