Manual deployments are a productivity tax disguised as a "quick task." In reality, a manual deployment takes 30-60 minutes of engineer time, has a non-zero failure rate, and creates anxiety every time. On a team deploying twice a week, that is 4-8 hours per month of pure waste β€” plus the cost of any deployment-induced incidents.

This guide builds a complete CI/CD pipeline from scratch. Every step is explained so you understand why it is there.

β†’ What CI/CD Actually Means

Continuous Integration (CI): Every code change triggers an automated process that builds, tests, and reports whether the change is safe to merge. Bugs are caught before they reach the main branch.

Continuous Deployment (CD): Every merge to main automatically deploys to production. Deployments become so routine they are not events worth worrying about.

Why this matters: Deployment confidence directly affects feature velocity. Teams that can deploy 10 times per day ship more value than teams that deploy once a week out of fear.

β†’ The Complete Pipeline

Developer opens a Pull Request
    ↓
GitHub Actions: Install deps β†’ PHPStan β†’ PHPUnit tests β†’ Coverage >= 80%
    ↓ (PR merged to main)
GitHub Actions: Build Docker β†’ Trivy security scan β†’ Push to ECR β†’ Run DB migrations β†’ Deploy to ECS β†’ Health check
    ↓ (if health check fails)
Automatic rollback to previous version

β†’ Step 1: Repository Setup

.github/workflows/
    ci.yml       ← runs on every PR
    deploy.yml   ← runs on merge to main
.env.testing     ← environment variables for tests
Dockerfile

The .env.testing file:

env
APP_ENV=testing
APP_KEY=base64:test-key-here
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=testing
DB_USERNAME=root
DB_PASSWORD=secret
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

Using array drivers for cache, session, and mail means tests run without any external dependencies β€” identically on any machine.

β†’ Step 2: The CI Workflow

yaml
# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.2', '8.3']

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: secret
          MYSQL_DATABASE: testing
        ports: ['3306:3306']
        options: --health-cmd="mysqladmin ping" --health-interval=10s

      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
        options: --health-cmd="redis-cli ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP ${{ matrix.php }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: pdo_mysql, redis, opcache, gd, zip
          coverage: xdebug

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - run: composer install --no-interaction --prefer-dist

      - run: cp .env.testing .env && php artisan key:generate

      - run: php artisan migrate --force

      - name: PHPStan static analysis
        run: ./vendor/bin/phpstan analyse --level=8 --memory-limit=512M

      - name: Run tests with coverage
        run: php artisan test --parallel --coverage-clover coverage.xml --min=80

β†’ Step 3: The Deploy Workflow

yaml
# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

env:
  AWS_REGION: ap-south-1
  ECR_REPOSITORY: laravel-app
  ECS_CLUSTER: production
  ECS_SERVICE: laravel-app

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # required for OIDC auth
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsDeployRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Set image tag
        id: image
        run: echo "tag=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}" >> $GITHUB_OUTPUT

      - run: docker build -t ${{ steps.image.outputs.tag }} .

      - name: Scan for vulnerabilities (Trivy)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ steps.image.outputs.tag }}
          exit-code: 1
          severity: CRITICAL
          ignore-unfixed: true

      - run: docker push ${{ steps.image.outputs.tag }}

      - name: Run database migrations (one-off ECS task)
        run: |
          TASK_ARN=$(aws ecs run-task             --cluster ${{ env.ECS_CLUSTER }}             --task-definition laravel-migrate             --launch-type FARGATE             --overrides '{"containerOverrides":[{"name":"app","command":["php","artisan","migrate","--force"]}]}'             --query 'tasks[0].taskArn' --output text)

          aws ecs wait tasks-stopped --cluster ${{ env.ECS_CLUSTER }} --tasks $TASK_ARN

          EXIT_CODE=$(aws ecs describe-tasks             --cluster ${{ env.ECS_CLUSTER }} --tasks $TASK_ARN             --query 'tasks[0].containers[0].exitCode' --output text)

          [ "$EXIT_CODE" = "0" ] || (echo "Migration failed" && exit 1)

      - name: Update ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: app
          image: ${{ steps.image.outputs.tag }}

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

The OIDC authentication approach (role-to-assume) is more secure than long-lived AWS access keys stored as GitHub secrets. GitHub obtains a short-lived token directly from AWS.

β†’ Step 4: Zero-Downtime Database Migrations

The naive approach β€” run migration then deploy code β€” has race conditions:

Problem: During the window between migration and code deployment,
old code runs against a new schema (or vice versa).

The expand/contract pattern makes all changes backward-compatible:

Sprint 1: Add column as nullable (old code ignores it, new code reads it with fallback)
Sprint 2: Deploy code that writes to the new column
Sprint 3: Optional cleanup β€” make NOT NULL, remove fallback
php
// Always add new columns as nullable first
Schema::table('orders', function (Blueprint $table) {
    $table->decimal('discount', 10, 2)->nullable()->default(0)->after('total');
});

// Application code handles null gracefully during transition
$discount = $order->discount ?? 0;
$finalTotal = $order->total - $discount;

β†’ Step 5: Rollback Strategy

ECS keeps previous task definitions registered. Rollback is a single command:

bash
# Roll back to the previous task definition revision
aws ecs update-service   --cluster production   --service laravel-app   --task-definition laravel-app:PREVIOUS_REVISION   --force-new-deployment

For automated rollback, create a CloudWatch alarm on the 5xx error rate that triggers an SNS notification to a Lambda function which calls this rollback automatically.

β†’ Step 6: Laravel Build Optimisations

Run all caching commands in the Dockerfile:

dockerfile
RUN php artisan config:cache    # merges all config files β€” saves ~5ms/request
 && php artisan route:cache     # compiles routes β€” saves ~2ms/request
 && php artisan view:cache      # pre-compiles Blade templates
 && php artisan event:cache      # compiles event listeners

Together these save 15-20ms per request β€” significant at scale.

β†’ Measured ROI

For a Nepal-based team deploying daily:

MetricBeforeAfter
Deployment time45-60 min manual8-12 min automated
Deployment failures~15%~2% (test-gated)
Monthly time savedβ€”~30 engineer-hours
Deployment frequencyOnce/weekMultiple times/day
✨

Topics

#DevOps engineer Nepal#CI/CD#GitHub Actions#Docker#AWS consultant Nepal
πŸ’‘

Need Help With This?

Let's talk about how I can architect a scalable solution for your business.

Get In Touch β†’