404 Not Found

404 Not Found


nginx

Project Deployment — Launch of the ShopMetrics SaaS Platform

Going live isn't the end—it's just the beginning. After deployment, you still need to monitor, set up alerts, and back up the system to ensure stable 24/7 operation.

1. What You'll Learn


2. A True Story from Launch Day

(1) Pain Point: We didn't realize the database hadn't been migrated until after deployment

Bob led the ShopMetrics team through their first deployment—the code was pushed, Docker was up and running, and Nginx was configured. But as soon as Alice opened the dashboard, she got a 500 error. Charlie checked the logs and discovered that the newly added analytics_reports table didn't exist in the database—they'd forgotten to run the migration. To make matters worse, they hadn't backed up the database before deployment, so they had no choice but to run the migration on the production environment.

(2) A Solution for Systematizing the Deployment Process

Deployment isn't just "pushing code to production"—it's a 15-step checklist, and each step must pass verification before proceeding. CI/CD automates 10 of these steps, leaving only the health checks and business validations to be confirmed manually.

TEXT
Checklist: 15 steps, CI/CD automates 10, human verifies 5
Result: 0 missed steps, 0 post-deploy incidents

(3) Revenue

For Bob's second deployment, a systematic process was used—fully automated deployment plus health checks—completed in 3 minutes with zero errors.


3. Docker Compose Production Orchestration

(1) Production Architecture Overview

100%
flowchart TB
    subgraph Internet["Internet"]
        USER[Users]
        STRIPE[Stripe Webhooks]
    end

    subgraph LB["Load Balancer / CDN"]
        NGINX[Nginx Reverse Proxy]
    end

    subgraph App["Application Tier"]
        APP1[App Container 1]
        APP2[App Container 2]
    end

    subgraph Worker["Background Tier"]
        Q1[Queue Worker - High]
        Q2[Queue Worker - Default]
        SCHED[Scheduler]
    end

    subgraph Data["Data Tier"]
        MASTER[(MySQL Primary)]
        REPLICA[(MySQL Replica)]
        REDIS[(Redis Cluster)]
        S3[(S3 Storage)]
    end

    subgraph Monitor["Observability"]
        SENTRY[Sentry]
        TELESCOPE[Telescope]
        LOGS[Log Aggregation]
    end

    USER --> NGINX
    STRIPE --> NGINX
    NGINX --> APP1
    NGINX --> APP2
    APP1 --> MASTER
    APP1 --> REDIS
    APP2 --> REDIS
    APP1 --> S3
    Q1 --> MASTER
    Q1 --> REDIS
    Q1 --> S3
    SCHED --> Q1
    APP1 --> REPLICA
    APP2 --> REPLICA
    APP1 --> SENTRY
    APP1 --> TELESCOPE

(2) Docker Compose Production Configuration

YAML
# docker-compose.prod.yml
services:
  app:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 512M
          cpus: '1.0'
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - internal

  queue-high:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    command: php artisan queue:work --queue=high --sleep=3 --tries=3 --max-time=3600
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 256M
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    networks:
      - internal

  queue-default:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    command: php artisan queue:work --queue=default --sleep=3 --tries=3 --max-time=3600
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 256M
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    networks:
      - internal

  scheduler:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    command: php artisan schedule:work --verbose
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - internal

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --maxmemory 512mb
      --maxmemory-policy allkeys-lru
      --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/production.conf:/etc/nginx/conf.d/default.conf:ro
      - ./docker/nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - internal

volumes:
  mysql_data:
  redis_data:
  storage:

networks:
  internal:
    driver: bridge

(1) ▶ Example: ShopMetrics .env.production configuration

BASH
# .env.production
APP_NAME=ShopMetrics
APP_ENV=production
APP_KEY=base64:xxx
APP_DEBUG=false
APP_URL=https://shopmetrics.io
APP_VERSION=1.0.0

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=shopmetrics
DB_USERNAME=shopmetrics
DB_PASSWORD=${DB_PASSWORD}
DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PASSWORD=${REDIS_PASSWORD}

FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=shopmetrics-reports

STRIPE_KEY=pk_live_xxx
STRIPE_SECRET=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

SENTRY_LARAVEL_DSN=https://xxx@sentry.io/xxx
SENTRY_TRACES_SAMPLE_RATE=0.2

LOG_CHANNEL=daily
LOG_LEVEL=warning

Output:

TEXT
# Command executed successfully

4. CI/CD Pipeline

(1) Complete GitHub Actions workflow

YAML
# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
  push:
    branches: [main]
  workflow_dispatch:  # Manual trigger option

concurrency: production  # Only one deploy at a time

env:
  REGISTRY: ghcr.io
  IMAGE: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: shopmetrics_test
          MYSQL_USER: test
          MYSQL_PASSWORD: test
          MYSQL_ROOT_PASSWORD: test
        ports: ['3306:3306']
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: xdebug
      - name: Install & Test
        run: |
          composer install --no-progress
          cp .env.example .env
          php artisan key:generate
          php artisan test --parallel --coverage-text=coverage.txt
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.txt

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: composer install --no-dev
      - name: Security audit
        run: composer audit

  build:
    needs: [test, security]
    runs-on: ubuntu-latest
    permissions:
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/shopmetrics
            export IMAGE_TAG=${{ github.sha }}

            # Pull latest image
            docker compose -f docker-compose.prod.yml pull app queue-high queue-default scheduler

            # Rolling update: start new containers
            docker compose -f docker-compose.prod.yml up -d --no-deps --scale app=3 app
            sleep 10

            # Health check new containers
            for i in $(seq 1 5); do
              if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
                echo "✓ Health check passed"
                break
              fi
              echo "Waiting for health check... ($i/5)"
              sleep 5
            done

            # Run migrations
            docker compose -f docker-compose.prod.yml exec -T app php artisan migrate --force

            # Restart workers (graceful)
            docker compose -f docker-compose.prod.yml exec -T app php artisan queue:restart

            # Scale down old containers
            docker compose -f docker-compose.prod.yml up -d --scale app=2

            # Clear and rebuild caches
            docker compose -f docker-compose.prod.yml exec -T app php artisan optimize:clear
            docker compose -f docker-compose.prod.yml exec -T app php artisan optimize

            echo "✓ Deploy complete: $IMAGE_TAG"

  verify:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - name: Smoke test production
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://shopmetrics.io/health)
          if [ "$STATUS" != "200" ]; then
            echo "✗ Health check failed: HTTP $STATUS"
            exit 1
          fi
          echo "✓ Production health check passed"

      - name: Notify Slack on success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text":"✅ ShopMetrics deployed successfully: ${{ github.sha }}"}

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text":"🚨 ShopMetrics deployment FAILED: ${{ github.sha }}"}

(1) ▶ Example: ShopMetrics Database Migration Strategy

PHP
// Safe migration patterns for production

// ✅ SAFE: Add column (non-breaking)
Schema::table('orders', function (Blueprint $table) {
    $table->string('shipping_method')->nullable()->after('status');
    $table->index('shipping_method');
});

// ✅ SAFE: Add index (concurrent in PostgreSQL)
// MySQL: Use ALGORITHM=INPLACE for non-blocking index creation
DB::statement('ALTER TABLE orders ADD INDEX idx_orders_shipping (shipping_method) ALGORITHM=INPLACE');

// ⚠️ CAUTION: Remove column (two-step migration)
// Step 1: Mark as deprecated (deploy first, remove code references)
Schema::table('orders', function (Blueprint $table) {
    // Keep old column, add new column
    $table->renameColumn('shipping_method', 'deprecated_shipping_method');
});

// Step 2: Remove after code no longer references it (next deploy)
Schema::table('orders', function (Blueprint $table) {
    $table->dropColumn('deprecated_shipping_method');
});

// ❌ DANGEROUS: Change column type (locks table)
// Use two-step: add new column → migrate data → drop old column
Schema::table('orders', function (Blueprint $table) {
    $table->unsignedBigInteger('total_cents_new')->nullable()->after('total_cents');
});

// Data migration in a separate migration
DB::statement('UPDATE orders SET total_cents_new = total_cents WHERE total_cents_new IS NULL');

Schema::table('orders', function (Blueprint $table) {
    $table->dropColumn('total_cents');
    $table->renameColumn('total_cents_new', 'total_cents');
});

Output:

TEXT
// Execution Successful

5. Health Checks and Monitoring

(1) Health Check Endpoints

PHP
// routes/web.php
Route::get('/health', function () {
    $start = microtime(true);
    $checks = [];

    // Database check
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Throwable $e) {
        $checks['database'] = 'fail: ' . $e->getMessage();
    }

    // Redis check
    try {
        Cache::put('health_check', 'ok', 10);
        $checks['redis'] = Cache::get('health_check') === 'ok' ? 'ok' : 'fail';
    } catch (\Throwable $e) {
        $checks['redis'] = 'fail: ' . $e->getMessage();
    }

    // S3 check
    try {
        Storage::disk('s3')->put('health_check.txt', 'ok');
        $checks['storage'] = Storage::disk('s3')->get('health_check.txt') === 'ok' ? 'ok' : 'fail';
    } catch (\Throwable $e) {
        $checks['storage'] = 'fail: ' . $e->getMessage();
    }

    // Queue check
    try {
        $size = Queue::size('default');
        $checks['queue'] = $size < 10000 ? "ok (size: {$size})" : "warn (size: {$size})";
    } catch (\Throwable $e) {
        $checks['queue'] = 'fail: ' . $e->getMessage();
    }

    $latency = round((microtime(true) - $start) * 1000, 2);
    $allOk = collect($checks)->every(fn ($v) => str_starts_with($v, 'ok'));

    return response()->json([
        'status' => $allOk ? 'healthy' : 'unhealthy',
        'checks' => $checks,
        'latency_ms' => $latency,
        'version' => config('app.version'),
        'timestamp' => now()->toIso8601String(),
    ], $allOk ? 200 : 503);
});

(2) Comparison of Monitoring Tools

Tool Type Free Tier Use Cases
Sentry Error Tracking 5K events/month Exception Capture + Performance Tracking
Laravel Telescope Debug Panel Free Development/Debugging (Restricted access in production)
Prometheus + Grafana Metrics Monitoring Free Self-Hosted System Metrics + Custom Dashboards
UptimeRobot Availability Monitoring 50 monitors HTTP Health Check Alerts
Algolia/Meilisearch Log Search Limited Free Log Aggregation and Search

(1) ▶ Example: ShopMetrics Sentry Configuration and Custom Context

PHP
// config/sentry.php
return [
    'dsn' => env('SENTRY_LARAVEL_DSN'),
    'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE', 0.2),
    'send_default_pii' => false,
    'environment' => app()->environment(),
    'release' => config('app.version'),
];

// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
    public function report(Throwable $e): void
    {
        if (app()->bound('sentry') && $this->shouldReport($e)) {
            \Sentry\configureScope(function (\Sentry\State\Scope $scope) {
                if ($user = auth()->user()) {
                    $scope->setUser([
                        'id' => $user->id,
                        'email' => $user->email,
                        'tenant_id' => $user->tenant_id,
                    ]);
                }

                $scope->setTag('app_version', config('app.version'));
                $scope->setExtra('request_url', request()->url());
                $scope->setExtra('request_method', request()->method());
            });
        }

        parent::report($e);
    }
}

// app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if (app()->environment('local')) {
            Telescope::listenStorageNotifications();
        }

        Telescope::filter(function (IncomingEntry $entry) {
            if (app()->environment('local')) return true;
            return $entry->isReportableException() ||
                   $entry->isFailedJob() ||
                   $entry->isScheduledTask() ||
                   $entry->hasMonitoredTag();
        });
    }
}

Output:

TEXT
// Execution Successful

6. Database Backup Plan

(1) Backup Strategy

Backup Type Frequency Retention Storage Location Recovery Time
Full Backup Daily at 3:00 AM 30 days S3 (off-site) 30-60 minutes
Incremental Backup Hourly 7 days S3 (off-site) 60-120 minutes
Binlog Real-time 7 days Local + S3 PITR at any point in time

(2) Automatic Backup Script

BASH
#!/bin/bash
# backup-mysql.sh — Daily MySQL backup to S3
set -e

DB_NAME="shopmetrics"
S3_BUCKET="s3://shopmetrics-backups/mysql"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/tmp/shopmetrics_${DATE}.sql.gz"

echo "→ Starting backup: ${DATE}"

# Dump with consistent snapshot (no lock)
mysqldump \
  --host=mysql \
  --user=${DB_USERNAME} \
  --password=${DB_PASSWORD} \
  --single-transaction \
  --routines \
  --triggers \
  --quick \
  ${DB_NAME} | gzip > ${BACKUP_FILE}

# Upload to S3
aws s3 cp ${BACKUP_FILE} ${S3_BUCKET}/daily/shopmetrics_${DATE}.sql.gz

# Cleanup local file
rm -f ${BACKUP_FILE}

# Cleanup backups older than 30 days
aws s3 rm ${S3_BUCKET}/daily/ --recursive --exclude "*" --include "shopmetrics_*" \
  --query "Contents[?LastModified<='$(date -d '-30 days' +%Y-%m-%d)'].Key"

echo "✓ Backup complete: shopmetrics_${DATE}.sql.gz"

# Verify backup integrity
LATEST=$(aws s3 ls ${S3_BUCKET}/daily/ | tail -1 | awk '{print $4}')
aws s3 cp ${S3_BUCKET}/daily/${LATEST} /tmp/verify.sql.gz
if gzip -t /tmp/verify.sql.gz; then
    echo "✓ Backup integrity verified"
else
    echo "✗ Backup integrity check FAILED"
    exit 1
fi
rm -f /tmp/verify.sql.gz

(1) ▶ Example: ShopMetrics Automatic Backup Schedule

PHP
// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Daily database backup
Schedule::command('shopmetrics:backup-database')
    ->dailyAt('03:00')
    ->onOneServer()
    ->withoutOverlapping()
    ->emailOutputOnFailure('ops@shopmetrics.io');

// Hourly S3 storage sync (for report files)
Schedule::command('shopmetrics:sync-storage-backup')
    ->hourly()
    ->onOneServer();

// Weekly backup integrity test
Schedule::command('shopmetrics:verify-backup')
    ->weeklyOn(Schedule::SUNDAY, '04:00')
    ->onOneServer()
    ->emailOutputOnFailure('ops@shopmetrics.io');

// app/Console/Commands/BackupDatabase.php
class BackupDatabase extends Command
{
    protected $signature = 'shopmetrics:backup-database';
    protected $description = 'Backup MySQL database to S3';

    public function handle(): int
    {
        $filename = 'shopmetrics_' . now()->format('Ymd_His') . '.sql.gz';
        $tempPath = storage_path('app/backups/' . $filename);

        $this->info('Starting database backup...');

        $command = sprintf(
            'mysqldump --host=%s --user=%s --password=%s --single-transaction %s | gzip > %s',
            config('database.connections.mysql.host'),
            config('database.connections.mysql.username'),
            config('database.connections.mysql.password'),
            config('database.connections.mysql.database'),
            $tempPath
        );

        $exitCode = Process::run($command)->exitCode();

        if ($exitCode !== 0) {
            $this->error('Backup failed!');
            return self::FAILURE;
        }

        $s3Path = 'mysql/daily/' . $filename;
        Storage::disk('s3-backup')->put($s3Path, file_get_contents($tempPath));
        unlink($tempPath);

        $this->info("Backup uploaded: {$s3Path}");
        return self::SUCCESS;
    }
}

Output:

TEXT
// Execution Successful

7. Deployment Checklist and Rollback Plan

(1) Launch Checklist

# Check Item Command/Action Automation Meets Standard
1 Code passed testing php artisan test CI 0 failures
2 Security audit passed composer audit CI 0 vulnerabilities
3 Docker Image Build docker build CI Build Successful
4 Pull the latest image docker compose pull CD Image exists
5 Start a new container docker compose up -d CD Container running
6 Health check passed curl /health CD HTTP 200
7 Database Migration migrate --force CD 0 errors
8 Cache Warm-up config:cache + route:cache CD Command Successful
9 Queue Worker Restart queue:restart CD Worker Restart
10 Clean up old containers docker image prune CD Disk space is normal
11 Business Smoke Test Manual Testing of Key Processes Manual Functionality Normal
12 Monitoring confirmed No anomalies in Sentry/Telescope Manual 0 errors in 5 min
13 Log Check hilog No ERROR Manual No new exceptions
14 Performance Validation Dashboard load < 500 ms Manual p95 < 500 ms
15 Team Notification Slack/Email Notification Automatic Sent

(2) Rollback Plan

Scenario Detection Method Rollback Procedure Recovery Time
Health Check Failed CI/CD Automated Detection docker compose pull <previous-tag> + up < 1 minute
Migration Failed CI/CD Logs migrate:rollback + Rollback Code < 5 minutes
Business Logic Error User Feedback/Monitoring Blue-Green Rollback to Previous Slot < 30 seconds
Database Corruption Monitoring Alerts S3 Backup and Restore + Binlog Replay 30-120 minutes

(1) ▶ Example: ShopMetrics Rollback Script

BASH
#!/bin/bash
# rollback.sh — Emergency rollback script
set -e

COMPOSE_FILE="docker-compose.prod.yml"
BACKUP_DIR="/opt/shopmetrics/backups"

echo "=== EMERGENCY ROLLBACK ==="

# Get previous image tag
PREVIOUS_TAG=$(cat ${BACKUP_DIR}/last_successful_deploy_tag.txt)
CURRENT_TAG=$(cat ${BACKUP_DIR}/current_deploy_tag.txt)

echo "Current: ${CURRENT_TAG}"
echo "Rolling back to: ${PREVIOUS_TAG}"

# Step 1: Set image tag to previous version
export IMAGE_TAG=${PREVIOUS_TAG}

# Step 2: Pull previous image
docker compose -f ${COMPOSE_FILE} pull app queue-high queue-default scheduler

# Step 3: Start previous version containers
docker compose -f ${COMPOSE_FILE} up -d --no-deps --scale app=2 app
docker compose -f ${COMPOSE_FILE} up -d --no-deps queue-high queue-default scheduler

# Step 4: Wait and verify
sleep 10
if curl -sf http://localhost:8080/health > /dev/null; then
    echo "✓ Health check passed after rollback"
else
    echo "✗ Health check FAILED after rollback — MANUAL INTERVENTION REQUIRED"
    exit 1
fi

# Step 5: Check if DB migration rollback is needed
echo "⚠ Check if database migrations need rollback:"
echo "  Run: docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate:status"
echo "  If needed: docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate:rollback --step=N"

# Step 6: Update current tag
echo "${PREVIOUS_TAG}" > ${BACKUP_DIR}/current_deploy_tag.txt

# Step 7: Notify team
echo "⚠ ROLLBACK COMPLETE — Notify team immediately"
echo "  Rolled back from ${CURRENT_TAG} to ${PREVIOUS_TAG}"

Output:

TEXT
CONTAINER ID   IMAGE     STATUS    
abc123         latest    Up 2 hours

8. Comprehensive Example: The Full Process of Launching ShopMetrics

BASH
#!/bin/bash
# ============================================
# Comprehensive: ShopMetrics full deployment script
# Covers: backup → deploy → migrate → verify → rollback plan
# ============================================

set -euo pipefail

DEPLOY_TAG=${1:-$(git rev-parse --short HEAD)}
COMPOSE_FILE="docker-compose.prod.yml"
BACKUP_DIR="/opt/shopmetrics/backups"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL}"

log() { echo "[$(date +%H:%M:%S)] $1"; }
notify() { curl -s -X POST "${SLACK_WEBHOOK}" -H 'Content-type: application/json' --data "{\"text\":\"$1\"}" > /dev/null 2>&1 || true; }

# Pre-deploy: Backup
log "→ Step 1/10: Database backup"
php artisan shopmetrics:backup-database
log "✓ Backup complete"

# Pre-deploy: Save current state
log "→ Step 2/10: Saving current state"
cat ${BACKUP_DIR}/current_deploy_tag.txt > ${BACKUP_DIR}/last_successful_deploy_tag.txt 2>/dev/null || true
echo "${DEPLOY_TAG}" > ${BACKUP_DIR}/current_deploy_tag.txt
log "✓ State saved (rolling back to: $(cat ${BACKUP_DIR}/last_successful_deploy_tag.txt 2>/dev/null || echo 'N/A'))"

# Deploy: Pull and start
log "→ Step 3/10: Pulling image ${DEPLOY_TAG}"
export IMAGE_TAG=${DEPLOY_TAG}
docker compose -f ${COMPOSE_FILE} pull app queue-high queue-default scheduler

log "→ Step 4/10: Starting new containers"
docker compose -f ${COMPOSE_FILE} up -d --no-deps --remove-orphans app queue-high queue-default scheduler

# Health check with retry
log "→ Step 5/10: Health check"
for i in $(seq 1 6); do
    if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
        log "✓ Health check passed"
        break
    fi
    if [ $i -eq 6 ]; then
        log "✗ Health check failed after 30s — ROLLING BACK"
        bash /opt/shopmetrics/rollback.sh
        notify "🚨 ShopMetrics deploy FAILED (health check) — rolled back"
        exit 1
    fi
    log "  Waiting... ($i/6)"
    sleep 5
done

# Migrate
log "→ Step 6/10: Running migrations"
docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate --force
if [ $? -ne 0 ]; then
    log "✗ Migration failed — ROLLING BACK"
    bash /opt/shopmetrics/rollback.sh
    notify "🚨 ShopMetrics deploy FAILED (migration) — rolled back"
    exit 1
fi
log "✓ Migrations complete"

# Optimize
log "→ Step 7/10: Caching"
docker compose -f ${COMPOSE_FILE} exec -T app php artisan optimize:clear
docker compose -f ${COMPOSE_FILE} exec -T app php artisan optimize
docker compose -f ${COMPOSE_FILE} exec -T app php artisan queue:restart
log "✓ Caches rebuilt"

# Post-deploy verification
log "→ Step 8/10: Smoke tests"
SMOKE_PASS=true
for endpoint in "/health" "/api/v1/plans"; do
    STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://shopmetrics.io${endpoint}")
    if [ "$STATUS" -ge 400 ]; then
        log "✗ Smoke test failed: ${endpoint} → HTTP ${STATUS}"
        SMOKE_PASS=false
    fi
done
if [ "$SMOKE_PASS" = false ]; then
    log "✗ Smoke tests failed — ROLLING BACK"
    bash /opt/shopmetrics/rollback.sh
    notify "🚨 ShopMetrics deploy FAILED (smoke test) — rolled back"
    exit 1
fi
log "✓ Smoke tests passed"

# Cleanup
log "→ Step 9/10: Cleanup"
docker image prune -f > /dev/null
log "✓ Old images cleaned"

# Success
log "→ Step 10/10: Final verification"
sleep 5
ERRORS=$(docker compose -f ${COMPOSE_FILE} logs --since 2m app 2>&1 | grep -c "ERROR" || true)
if [ "${ERRORS}" -gt 5 ]; then
    log "⚠ High error rate detected: ${ERRORS} errors in 2 minutes"
    notify "⚠️ ShopMetrics deployed but high error rate: ${ERRORS} errors"
else
    log "✓ Deployment successful: ${DEPLOY_TAG}"
    notify "✅ ShopMetrics deployed successfully: ${DEPLOY_TAG}"
fi

log "=== DEPLOY COMPLETE ==="

❓ FAQ

Q Should we use Docker Compose or Kubernetes in a production environment?
A Docker Compose is sufficient for up to 5 servers, as it simplifies operations. Migrate to Kubernetes when you have more than 5 servers or need automatic scaling. ShopMetrics started with Docker Compose and will consider Kubernetes once user numbers exceed 10,000.
Q What should I do if a migration fails in the production environment?
A In CI/CD, a failed migration automatically blocks deployment and does not switch traffic. Simply roll back to the previous code version (the database schema remains compatible with the old version, since migrations follow the "add first, then delete" principle).
Q Is Sentry's free tier sufficient?
A 5,000 events per month is enough for the early stages. Once you exceed that limit, you can switch to a pay-as-you-go plan or self-host Sentry (open source and free, but you'll need to maintain your own server). We recommend keeping Sentry enabled at all times in production environments.
Q How long does it take to back up and restore a database?
A A full restore of a 10GB database takes approximately 30 minutes (including download from S3 and import). Enabling binlog allows for point-in-time recovery (PITR), with a recovery time of approximately 60-120 minutes. Perform a recovery drill at least once a month.
Q How do I know when to scale up?
A Monitor the following metrics: CPU > 70% for 5 consecutive minutes, queue backlog > 1,000, and p95 latency > 500 ms. Set alert thresholds; when triggered, first investigate whether optimization is possible. Only scale up after confirming it is a capacity issue.
Q What happens to the database after a rollback?
A Follow the "add first, then delete" migration principle—the new version only adds columns without modifying existing ones, so the database structure remains compatible after rolling back the code. Deletion operations should be deferred until at least one version later. This way, a rollback affects only the code, not the database.

📖 Summary


📝 Exercises

  1. Basic Problem (⭐): Write a Docker Compose production configuration for ShopMetrics that includes six services: app, queue-worker, scheduler, mysql, redis, and nginx. All services must include health checks and restart policies.

  2. Advanced Exercise (⭐⭐): Write a complete GitHub Actions workflow: test → security audit → build Docker → deploy → smoke test → Slack notification. Include logic for automatic rollback in case of a failed health check.

  3. Challenge (⭐⭐⭐): Design a comprehensive operations and maintenance system for ShopMetrics—(1) Health check endpoints covering DB/Redis/S3/Queue/S3-backup (2) Automated database backup + recovery scripts + monthly recovery drills (3) Sentry error tracing + custom context (4) Automated 15-step deployment checklist script (5) Rollback scripts (code + database). Write an operations manual in Markdown format.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏