0x55aa
← Back to Blog

GitHub Actions Environments: Stop Deploying to Production Without Anyone Knowing šŸ”šŸš€

•9 min read

GitHub Actions Environments: Stop Deploying to Production Without Anyone Knowing šŸ”šŸš€

Let me paint you a picture.

It's 11:43 PM. I'm "just quickly shipping a small fix." Nobody's watching. The CI pipeline is green. I click merge. GitHub Actions kicks off. Forty-five seconds later, the new code is live in production.

At 11:46 PM, my phone starts buzzing.

Turns out "small fix" had a logic error that corrupted user preferences for anyone who logged in that evening. Small. Just a few hundred users. No big deal. (It was a very big deal.)

The worst part? Nothing in my pipeline said "hey, maybe get another set of eyes before you nuke production at midnight." No approval. No checkpoint. No human in the loop. Just me, my terrible judgment, and an automated pipeline that trusted me way too much.

Enter GitHub Actions Environments — the feature that adds a velvet rope in front of your production deployments. And yes, I set this up the very next morning.

What Are GitHub Actions Environments? šŸ¤”

GitHub Actions Environments let you define named deployment targets — staging, production, qa, whatever — with specific rules attached:

  • Required reviewers: A human must approve before the job runs
  • Wait timers: Force a cooling-off period (e.g., wait 10 minutes before deploying)
  • Environment-scoped secrets: Production credentials that only production jobs can access
  • Deployment branch rules: Only main can deploy to production. No feature branches sneaking through.

Think of it as hiring a bouncer for your production environment. The bouncer doesn't write any code. The bouncer just looks you in the eye and asks: "Are you sure?"

Sometimes that's all it takes.

The Deployment Horror Story That Made Me Set This Up šŸ’€

After years deploying Laravel and Node.js apps to AWS, I had a confidence problem. Specifically: too much of it.

Our GitHub Actions pipeline looked like this:

# The dangerous old way 😬
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        run: |
          ssh deployer@prod-server "cd /app && git pull && npm run build"

Merge to main = instant production deploy. No questions asked. No humans consulted.

What could go wrong? (Everything. Everything could go wrong.)

The incident: A developer on my team merged a PR that passed all 47 tests. What the tests didn't catch: the new feature used a config key named payment_gateway but production had it as PAYMENT_GATEWAY. Case-sensitive. Tests used mocked config. Production used real config.

Deployments on a Friday afternoon: āœ…
Tests passing: āœ…
Code review: āœ…
Someone checking if the feature works in staging first: āŒ

Result: Payment processing broken in production for 23 minutes. On a Friday. Before a holiday weekend.

With environment protection: Someone would have had to click "Approve" on the deployment. That one click would have bought us 30 seconds of "wait, did we test this on staging?" That 30 seconds would have saved 23 minutes of panic.

Setting Up Environments: Step by Step āš™ļø

Step 1: Create Your Environments in GitHub

Go to your repo → Settings → Environments → New environment

Create at minimum:

  • staging (no protection, auto-deploy)
  • production (protection required)

For production, configure:

  • āœ… Required reviewers (add yourself + at least one teammate)
  • āœ… Wait timer: 5 minutes (forces intentional deploys)
  • āœ… Deployment branch rules: Allow only main

Step 2: Update Your Workflow to Use Environments

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  # First: always deploy to staging automatically
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging  # No protection - auto-deploys
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-south-1

      - name: Deploy to Staging
        run: |
          echo "Deploying to staging..."
          aws s3 sync ./dist s3://my-staging-bucket
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.STAGING_CF_ID }} \
            --paths "/*"
          echo "āœ… Staging deployment complete!"

  # Second: deploy to production ONLY after approval
  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging  # Must succeed on staging first
    environment: production  # šŸ” This triggers the approval gate!
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS (production credentials)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          # Note: these are DIFFERENT secrets from staging!
          # They only exist in the production environment
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-south-1

      - name: Deploy to Production
        run: |
          echo "Deploying to production..."
          aws s3 sync ./dist s3://my-production-bucket
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.PROD_CF_ID }} \
            --paths "/*"
          echo "šŸš€ Production deployment complete!"

Now when you merge to main:

  1. Staging deploys automatically
  2. Production deployment pauses and waits for a human to click Approve
  3. The reviewer gets a GitHub notification with a link
  4. They review, click Approve (or Reject)
  5. Production deploys (or doesn't)

A CI/CD pipeline that saved our team from at least three midnight incidents. Worth every minute of setup.

Environment-Scoped Secrets: The Underrated Feature šŸ”‘

Here's something I didn't realize for way too long: environment secrets are completely separate from repository secrets.

Repository secrets (accessible to ALL jobs):
  - SLACK_WEBHOOK
  - DATADOG_API_KEY

Staging environment secrets (only staging jobs):
  - AWS_ACCESS_KEY_ID  → Points to staging AWS account
  - DATABASE_URL       → Points to staging database

Production environment secrets (only approved production jobs):
  - AWS_ACCESS_KEY_ID  → Points to production AWS account
  - DATABASE_URL       → Points to production database

Why this matters: Your staging job literally cannot access production credentials, even if someone tries to abuse the pipeline. The secrets are scoped to the environment, and the environment requires approval.

This eliminates an entire class of "oops, the CI job ran against production when it shouldn't have" incidents. Docker taught me the hard way that keeping environments isolated prevents cross-contamination — environment-scoped secrets are the CI/CD equivalent.

Adding a Manual Approval Notification to Slack šŸ“£

Waiting for someone to check GitHub is friction. Friction kills workflows. Send the approval request to Slack:

  notify-approval-needed:
    runs-on: ubuntu-latest
    needs: deploy-staging
    steps:
      - name: Notify team that production approval is needed
        uses: slackapi/[email protected]
        with:
          payload: |
            {
              "text": "šŸš€ *Production Deployment Ready for Approval*",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*${{ github.actor }}* wants to deploy `${{ github.sha }}` to production.\n\n*Branch:* ${{ github.ref_name }}\n*Commit:* ${{ github.event.head_commit.message }}"
                  }
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "Review & Approve →" },
                      "url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

  deploy-production:
    needs: [deploy-staging, notify-approval-needed]
    environment: production
    # ... rest of production deploy steps

Now when staging is green, your team gets a Slack message with a direct link to approve. No hunting through GitHub. No missing the notification. Just: "hey, someone wants to push to prod, go click this."

Before/After: What Changes When You Add Environments šŸ’”

Before (the cowboy way):

What happens Reality
Merge PR Instantly in production
Broken staging feature Also instantly in production
11 PM "quick fix" In production at 11:01 PM
Wrong credentials used You find out in production
No audit trail "Who deployed this?" (nobody knows)

After (the civilized way):

What happens Reality
Merge PR Auto-deploys to staging, pauses for production
Broken staging feature Caught in staging, reviewer rejects prod deploy
11 PM "quick fix" Reviewer asks "can this wait until morning?"
Wrong credentials Impossible — envs are isolated
Full audit trail GitHub shows who approved, when, what SHA

The GitHub UI even gives you a deployment history with timestamps and approvers. After countless deployments where we had no idea what was in production or when it got there, having a proper audit trail felt like a revelation.

Common Pitfalls to Avoid 🪤

Pitfall #1: Making Yourself the Only Reviewer

If you're the only required reviewer, you can approve your own deployments. That's not an approval system — that's a rubber stamp with extra steps.

Fix: Add at least two reviewers. Any one of them can approve, but you can't approve your own work.

Pitfall #2: Skipping the Wait Timer

A 5-minute wait timer sounds annoying. It's not — it's a forced sanity check. During those 5 minutes:

  • You can double-check staging is actually healthy
  • You can verify the right commit is being deployed
  • You can change your mind without drama

After countless deployments, the times I wished I had waited 5 minutes outnumber the times I was glad I deployed instantly by about 10:1.

Pitfall #3: Protecting Staging Too Aggressively

Staging should be fast and automatic. If developers have to get approval to deploy to staging, staging stops getting used. Then everyone tests in production. Then everyone is having a bad time.

Rule: Staging = auto-deploy. Production = approval required. Don't blur this line.

Pitfall #4: Not Linking Environments to Branch Rules

Someone on your team will try to deploy a feature branch to production "just this once." Protect against it in the environment settings: only allow deployments from main.

# GitHub environment settings (in the UI):
# Deployment branches: Selected branches
# Branch name pattern: main

One line. Saves you the conversation you don't want to have.

TL;DR: Your Environment Protection Cheat Sheet šŸŽÆ

The 30-second setup:

  1. Go to repo Settings → Environments → New environment
  2. Create staging (no protection) and production (required reviewers + wait timer)
  3. Add environment: production to your production deploy job
  4. Add needs: deploy-staging to ensure staging runs first
  5. Put prod secrets in the production environment, not in repo secrets
  6. (Bonus) Send a Slack notification so approvals don't get missed

The mindset shift:

  • Old me: "CI is green, ship it!"
  • New me: "CI is green on staging. Let's get a human to confirm before we touch production."

That human in the loop doesn't slow you down. They slow down your mistakes. Those two things are very different.

Your pipeline is allowed to be fast. Your production deployments should be deliberate.


Set up GitHub Environments and it saved you from a disaster? Hit me up on LinkedIn — I love a good "the approval system caught a bug" story.

Want the full workflow template? It's on my GitHub — tested in anger on real production systems.

Now go add a reviewer to your production environment. Future you will be grateful. šŸ”