Skip to content
5 min read·Lesson 5 of 10

Environments, Approvals, and Promotion

Promote a build through dev, staging, and production using environments, manual approvals, branch strategies, and trunk-based development.

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

ScopeUsed for
Repo secretsThings common to all environments
Environment secretsPer-environment credentials (different DB password per env)
Org secretsShared across many repos (NPM token, registry creds)
Repo / env variablesNon-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:

  1. Trunk-based development
  2. Feature flags
  3. A <15-minute pipeline
  4. Tests engineers actually trust
  5. Progressive delivery (canary, blue/green) so each deploy risks little
  6. 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.

Key Takeaways

  • Environments group deployment targets and gate them with approvals and protections.
  • Trunk-based development plus feature flags scales better than long-lived branches.
  • Promote a single artefact through environments — never rebuild per environment.
  • Manual approval steps belong on production, not on every change.
  • Concurrency controls prevent two deploys from racing each other.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →