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:
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
# .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
# .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 // 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:
# 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:
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:
| Metric | Before | After |
|---|---|---|
| Deployment time | 45-60 min manual | 8-12 min automated |
| Deployment failures | ~15% | ~2% (test-gated) |
| Monthly time saved | β | ~30 engineer-hours |
| Deployment frequency | Once/week | Multiple times/day |
Need Help With This?
Let's talk about how I can architect a scalable solution for your business.
Get In Touch β