Skip to content

CI/CD for Multiple Environments SOP

Version: 1.0
Owner: DevOps Team

Architecture

Feature branch → Develop → Staging → Production
     │              │          │          │
   GitHub PR      Auto      Auto     Manual +
     build       deploy    deploy    auto deploy

Environment Matrix

Environment Branch Trigger Approval URL
Development develop Push Auto dev.example.com
Staging release/* PR to main Auto staging.example.com
Production main Push Manual example.com

Workflow Structure

.github/workflows/
├── ci.yml              # Build & test (all branches)
├── deploy-dev.yml      # Auto-deploy develop
├── deploy-staging.yml  # Auto-deploy release branches
└── deploy-prod.yml     # Manual deploy main

CI (Build & Test)

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [develop, main, release/**]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint
        run: |
          # PHP
          composer lint
          # Node
          npm run lint
          # Dockerfile
          docker run --rm -v $PWD:/code hadolint/hadolint hadolint /code/Dockerfile

  test:
    needs: lint
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mariadb:10
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_DATABASE: test
        ports:
          - 3306:3306
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          cp .env.testing .env
          composer install
          php artisan migrate --env=testing
          php vendor/bin/phpunit

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build Docker image
        run: |
          docker build -t app:${{ github.sha }} .
      - name: Push to registry
        run: |
          echo ${{ secrets.REGISTRY_PASSWORD }} | docker login ${{ secrets.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
          docker tag app:${{ github.sha }} ${{ secrets.REGISTRY }}/app:latest
          docker tag app:${{ github.sha }} ${{ secrets.REGISTRY }}/app:${{ github.sha }}
          docker push --all-tags ${{ secrets.REGISTRY }}/app

Deploy to Development

.github/workflows/deploy-dev.yml
name: Deploy to Development

on:
  push:
    branches: [develop]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: development
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEV_HOST }}
          username: ${{ secrets.DEV_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/app
            git pull origin develop
            docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
            docker compose exec -T app php artisan migrate --force
            echo "Deployed to development"

Deploy to Staging

.github/workflows/deploy-staging.yml
name: Deploy to Staging

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging
    if: startsWith(github.head_ref, 'release/')
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/app
            git fetch origin
            git checkout ${{ github.head_ref }}
            docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d --build
            docker compose exec -T app php artisan migrate --force
            echo "Staging: ${{ github.event.pull_request.html_url }}"

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `🚀 Deployed to staging: https://staging.example.com`
            })

Deploy to Production

.github/workflows/deploy-prod.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  approve:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "Waiting for manual approval"

  deploy:
    needs: approve
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/app
            git pull origin main
            docker compose pull app
            docker compose up -d --force-recreate app
            docker compose exec -T app php artisan migrate --force
            docker image prune -f

      - name: Health check
        run: |
          sleep 10
          curl -f https://example.com/health

      - name: Notify
        run: |
          curl -X POST -H "Content-type: application/json" \
            --data '{"text":"🚀 Production deploy completed: ${{ github.sha }}"}' \
            ${{ secrets.SLACK_WEBHOOK }}

Environment Files Strategy

.env          # Base (committed, example values)
.env.dev      # Development overrides
.env.staging  # Staging overrides
.env.prod     # Production (secrets, NOT committed)

.env.example

APP_NAME=App
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost

DB_HOST=db
DB_DATABASE=app

Load by Environment

cp .env.example .env
cp .env.prod .env  # On production server

Docker Compose Per Environment

docker-compose.override.yml
# Auto-loaded for development
services:
  app:
    volumes:
      - ./app:/var/www/html
    ports:
      - "5173:5173" # Vite
docker-compose.staging.yml
services:
  app:
    build:
      args:
        - APP_ENV=staging
    ports:
      - "127.0.0.1:9000:9000"
docker-compose.prod.yml
services:
  app:
    build:
      args:
        - APP_ENV=production
    ports:
      - "127.0.0.1:9000:9000"
    restart: always

Secrets Per Environment

Store in GitHub → Settings → Environments:

Environment Secret Example
development DEV_HOST dev.example.com
staging STAGING_HOST staging.example.com
production PROD_HOST prod.example.com
production SLACK_WEBHOOK https://hooks.slack.com/...

Rollback

# In deploy-prod.yml, add rollback step:
- name: Rollback (if needed)
  if: failure()
  run: |
    ssh user@host "cd /opt/app && docker compose up -d app:previous-tag"

Verification

  • CI runs lint, test, build on every push
  • Develop auto-deploys to dev environment
  • Release branches auto-deploy to staging
  • Production requires manual approval
  • Each environment has its own secrets
  • Environment-specific docker-compose files
  • Rollback strategy documented
  • Notifications configured for each environment