404 Not Found

404 Not Found


nginx

Hands-On Guide to Deploying Laravel in Production

Deployment is the final step in releasing a product to the world—a single mistake could result in all users seeing a 500 error page.

1. What You'll Learn


2. A True Story from Launch Night

(1) Pain Point: Manual deployment always runs into problems

Every time Bob deploys ShopMetrics, he has to: SSH into the server → git pull → composer install → php artisan migrate → php artisan config:cache → restart the queue worker → restart PHP-FPM. With five servers, this manual process takes 40 minutes. Last month, he forgot to run the migration once, causing a site-wide 500 error that resulted in a loss of 2,000 USD. Charlie said, "You're deploying a 2024 SaaS product using a 1990s method."

(2) The Docker + CI/CD Solution

Docker containerization ensures that every environment is completely consistent, and CI/CD pipelines reduce deployment to a single "git push" command—testing, building, and deployment are all fully automated.

TEXT
git push → GitHub Actions → Test → Build Docker → Deploy → Health Check → Done

(3) Revenue

After Bob implemented CI/CD, deployment time dropped from 40 minutes to 3 minutes, the error rate fell from 10% to 0%, and he no longer has to SSH into the server in the middle of the night.


3. Docker Containerization

(1) Multi-stage Dockerfile

DOCKERFILE
# Dockerfile
# Stage 1: Build dependencies
FROM composer:2.7 AS build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-progress
COPY . .
RUN php artisan route:cache && \
    php artisan config:cache && \
    php artisan view:cache

# Stage 2: Production image
FROM php:8.3-fpm-alpine AS production
WORKDIR /var/www/html

RUN apk add --no-cache \
    nginx \
    supervisor \
    pdo_mysql \
    gd \
    zip \
    redis

RUN docker-php-ext-install pdo_mysql gd zip opcache redis

COPY --from=build /app /var/www/html
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf

RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

EXPOSE 8080
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

(2) Docker Compose Orchestration

YAML
# docker-compose.prod.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "8080:8080"
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  queue:
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    command: php artisan queue:work --queue=high,default --sleep=3 --tries=3
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage

  scheduler:
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    command: php artisan schedule:work
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

  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_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  mysql_data:
  redis_data:
  storage:

(1) ▶ Example: ShopMetrics Docker Development Environment

YAML
# docker-compose.yml (Development)
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8000:8000"
    volumes:
      - .:/var/www/html
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis
    depends_on:
      - mysql
      - redis
      - mailpit

  mysql:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: shopmetrics
      MYSQL_USER: shopmetrics
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: secret
    volumes:
      - mysql_dev:/var/lib/mysql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  mailpit:
    image: axllent/mailpit
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI

volumes:
  mysql_dev:

Output:

TEXT
CONTAINER ID   IMAGE          STATUS         PORTS
abc123         nginx:latest   Up 2 hours     0.0.0.0:80->80/tcp

4. Nginx + PHP-FPM Configuration

(1) Nginx Configuration

NGINX
# docker/nginx.conf
server {
    listen 8080;
    server_name shopmetrics.io api.shopmetrics.io;
    root /var/www/html/public;
    index index.php;

    charset utf-8;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    # Max upload size
    client_max_body_size 20M;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    location ~ \.php$ {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;

        # Buffers for large payloads
        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;

        # Timeouts
        fastcgi_connect_timeout 60s;
        fastcgi_send_timeout 60s;
        fastcgi_read_timeout 60s;
    }

    # Deny access to static content
    location ~ /\.(?!well-known).* {
        deny all;
    }
}

(2) SSL Configuration (Let's Encrypt)

NGINX
# SSL termination at reverse proxy or load balancer
server {
    listen 443 ssl http2;
    server_name shopmetrics.io;

    ssl_certificate /etc/letsencrypt/live/shopmetrics.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shopmetrics.io/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    # HSTS
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        proxy_pass http://app:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name shopmetrics.io;
    return 301 https://$server_name$request_uri;
}

(1) ▶ Example: ShopMetrics Nginx Multi-Tenant Subdomain Routing

NGINX
# Route tenant subdomains
server {
    listen 443 ssl http2;
    server_name ~^(?<tenant>[^\.]+)\.shopmetrics\.io$;

    ssl_certificate /etc/letsencrypt/live/shopmetrics.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shopmetrics.io/privkey.pem;

    # Pass tenant slug to Laravel
    location / {
        proxy_pass http://app:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Tenant $tenant;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Output:

TEXT
// Execution Successful

5. CI/CD Pipeline

(1) GitHub Actions Workflows

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ 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 dependencies
        run: composer install --no-progress --prefer-dist
      - name: Copy .env
        run: cp .env.example .env && php artisan key:generate
      - name: Run tests
        run: php artisan test --parallel --coverage-text
      - name: Check code style
        run: vendor/bin/pint --test

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /var/www/shopmetrics
            docker compose -f docker-compose.prod.yml pull
            docker compose -f docker-compose.prod.yml up -d --remove-orphans
            docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
            docker compose -f docker-compose.prod.yml exec app php artisan config:cache
            docker compose -f docker-compose.prod.yml exec app php artisan queue:restart
            sleep 5
            curl -sf http://localhost:8080/health || exit 1
            echo "Deploy successful!"

(2) Deployment Flowchart

100%
flowchart LR
    A[git push main] --> B[GitHub Actions]
    B --> C[Run Tests]
    C -->|Pass| D[Build Docker Image]
    C -->|Fail| E[Notify + Block]
    D --> F[Push to Registry]
    F --> G[SSH to Production]
    G --> H[docker compose pull]
    H --> I[docker compose up -d]
    I --> J[php artisan migrate]
    J --> K[Cache + Queue Restart]
    K --> L[Health Check]
    L -->|OK| M[Deploy Done]
    L -->|Fail| N[Auto Rollback]

(1) ▶ Example: ShopMetrics Deployment Health Check Endpoint

PHP
// routes/web.php
Route::get('/health', function () {
    $checks = [
        'database' => fn () => DB::connection()->getPdo() ? 'ok' : 'fail',
        'redis' => fn () => Cache::put('health_check', 'ok', 10) ? 'ok' : 'fail',
        'storage' => fn () => Storage::put('health_check', 'ok') ? 'ok' : 'fail',
    ];

    $results = collect($checks)->map(fn ($check) => $check());

    if ($results->contains('fail')) {
        return response()->json([
            'status' => 'unhealthy',
            'checks' => $results,
        ], 503);
    }

    return response()->json([
        'status' => 'healthy',
        'checks' => $results,
        'timestamp' => now()->toIso8601String(),
        'version' => config('app.version', 'unknown'),
    ]);
});

Output:

TEXT
// Execution Successful

6. Deployment Checklist

(1) Pre-Launch Checklist

Step Command/Action Description
1 .env Configuration Check APP_ENV=production, APP_DEBUG=false
2 php artisan key:generate Verify that APP_KEY has been set
3 php artisan migrate --force Execute database migration
4 php artisan config:cache Cache Configuration
5 php artisan route:cache Caching Routing
6 php artisan view:cache Cached View
7 php artisan storage:link Create a storage link
8 php artisan queue:restart Restart Worker Queue
9 Permission Check storage/ + bootstrap/cache/ are writable
10 Health Check /health Back to 200

(1) ▶ Example: ShopMetrics Automated Deployment Script

BASH
#!/bin/bash
# deploy.sh — Zero-downtime deployment script
set -e

APP_DIR="/var/www/shopmetrics"
RELEASES_DIR="/var/www/releases"
CURRENT_LINK="/var/www/current"
NEW_RELEASE=$(date +%Y%m%d%H%M%S)

echo "=== Deploying ShopMetrics ==="

# 1. Create release directory
mkdir -p "$RELEASES_DIR/$NEW_RELEASE"
cd "$RELEASES_DIR/$NEW_RELEASE"

# 2. Clone/pull code
git clone --depth 1 --branch main git@github.com:bob/shopmetrics.git .
echo "→ Code pulled"

# 3. Install dependencies
composer install --no-dev --optimize-autoloader --no-progress
echo "→ Dependencies installed"

# 4. Copy environment file
cp "$APP_DIR/.env.production" .env

# 5. Optimize
php artisan config:cache
php artisan route:cache
php artisan view:cache
echo "→ Caches built"

# 6. Switch symlink (atomic)
ln -sfn "$RELEASES_DIR/$NEW_RELEASE" "$CURRENT_LINK"
echo "→ Symlink switched"

# 7. Run migrations
cd "$CURRENT_LINK"
php artisan migrate --force
echo "→ Migrations done"

# 8. Restart queue workers
php artisan queue:restart
echo "→ Queue workers restarting"

# 9. Health check
sleep 3
if curl -sf http://localhost:8080/health > /dev/null; then
    echo "✓ Health check passed"
else
    echo "✗ Health check failed! Rolling back..."
    ln -sfn "$RELEASES_DIR/$(ls -t $RELEASES_DIR | sed -n '2p')" "$CURRENT_LINK"
    echo "✗ Rolled back"
    exit 1
fi

# 10. Cleanup old releases (keep last 5)
ls -t "$RELEASES_DIR" | tail -n +6 | xargs -r rm -rf
echo "→ Old releases cleaned"

echo "=== Deploy complete! ==="

Output:

TEXT
{"status":"ok","data":{}}

7. Zero-Downtime Deployment

(1) Comparison of Deployment Strategies

Strategy Downtime Resource Usage Complexity Use Cases
Direct Deployment 5-30 seconds Low Low Low traffic/acceptable brief downtime
Blue-Green 0 2x Medium High availability requirements
Rolling Update 0 1.5x Medium Multi-instance deployment
Canary 0 1.1x High Large-scale / Requires gray-scale validation

(2) Blue-Green Deployment

100%
flowchart LR
    subgraph Blue["Blue (Current)"]
        B1[App v1.0]
        B2[DB v1.0]
    end
    subgraph Green["Green (New)"]
        G1[App v1.1]
        G2[DB v1.1]
    end
    LB[Load Balancer] -->|"100%"| B1
    LB -.->|"0%"| G1
    
    LB -->|"Switch after health check"| G1
BASH
# Blue-Green deployment with Docker
# Current: blue is live, green is idle
docker compose -f docker-compose.blue.yml up -d
# Health check green
curl -sf http://green:8080/health || exit 1
# Switch traffic (update nginx upstream)
echo "server green:8080;" > /etc/nginx/conf.d/upstream.conf
nginx -s reload
# Stop old blue
docker compose -f docker-compose.blue.yml down

(1) ▶ Example: ShopMetrics GitHub Actions Zero-Downtime Deployment

YAML
# .github/workflows/zero-downtime-deploy.yml
name: Zero-Downtime Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Determine active slot
        uses: appleboy/ssh-action@v1
        id: slot
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            if docker compose -f docker-compose.blue.yml ps | grep -q "Up"; then
              echo "ACTIVE=blue" >> $GITHUB_OUTPUT
              echo "DEPLOY=green" >> $GITHUB_OUTPUT
            else
              echo "ACTIVE=green" >> $GITHUB_OUTPUT
              echo "DEPLOY=blue" >> $GITHUB_OUTPUT
            fi

      - name: Deploy to inactive slot
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            DEPLOY=${{ steps.slot.outputs.DEPLOY }}
            docker compose -f docker-compose.${DEPLOY}.yml pull
            docker compose -f docker-compose.${DEPLOY}.yml up -d
            docker compose -f docker-compose.${DEPLOY}.yml exec app php artisan migrate --force
            docker compose -f docker-compose.${DEPLOY}.yml exec app php artisan config:cache

      - name: Health check & switch traffic
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            DEPLOY=${{ steps.slot.outputs.DEPLOY }}
            ACTIVE=${{ steps.slot.outputs.ACTIVE }}
            # Wait for health check
            for i in $(seq 1 10); do
              if curl -sf http://localhost:8080/health; then break; fi
              sleep 2
            done
            # Switch nginx upstream
            echo "server ${DEPLOY}:8080;" > /etc/nginx/conf.d/upstream.conf
            nginx -s reload
            # Stop old slot
            docker compose -f docker-compose.${ACTIVE}.yml down
            echo "Zero-downtime deploy complete!"

Output:

TEXT
CONTAINER ID   IMAGE          STATUS         PORTS
abc123         nginx:latest   Up 2 hours     0.0.0.0:80->80/tcp

8. Comprehensive Example: Complete Deployment and Configuration of ShopMetrics

DOCKERFILE
# ============================================
# Comprehensive: ShopMetrics Production Dockerfile
# Multi-stage build with optimization
# ============================================

# Stage 1: Build frontend assets
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY vite.config.ts tailwind.config.js postcss.config.js ./
COPY resources/ ./resources/
RUN npm run build

# Stage 2: Install PHP dependencies
FROM composer:2.7 AS backend
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-progress --classmap-authoritative
COPY . .
COPY --from=frontend /app/public/build ./public/build
RUN php artisan route:cache && \
    php artisan config:cache && \
    php artisan view:cache && \
    php artisan event:cache

# Stage 3: Production image
FROM php:8.3-fpm-alpine AS production
WORKDIR /var/www/html

RUN apk add --no-cache \
    curl nginx supervisor mysql-client \
    && docker-php-ext-install pdo_mysql opcache pcntl

COPY docker/opcache.ini /usr/local/etc/php/conf.d/
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

COPY --from=backend /app /var/www/html
RUN chown -R www-data:www-data storage bootstrap/cache

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT ["/entrypoint.sh"]
BASH
#!/bin/bash
# docker/entrypoint.sh
set -e

echo "→ Running migrations..."
php artisan migrate --force

echo "→ Starting supervisord..."
exec /usr/bin/supervisord -c /etc/supervisord.conf
INI
; docker/supervisord.conf
[supervisord]
nodaemon=true
user=root

[program:php-fpm]
command=php-fpm --nodaemonize
autorestart=true

[program:nginx]
command=nginx -g 'daemon off;'
autorestart=true

[program:queue-worker]
command=php artisan queue:work --queue=high,default --sleep=3 --tries=3 --max-time=3600
autorestart=true
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d

❓ FAQ

Q Which is better, Docker or traditional deployment?
A Docker's advantages include consistent environments, repeatable deployments, and easy scalability. Its disadvantages are the learning curve and a slight performance overhead (<5%). We recommend Docker for new projects; for existing projects using traditional deployment, a gradual migration is advised.
Q How do I manage SSL certificates?
A Use Let's Encrypt + Certbot for automatic renewal; or disable SSL on the cloud platform (AWS ALB/Cloudflare) and use only HTTP on the backend. Do not manage certificates manually.
Q Is database migration safe in a production environment?
A Adding columns or indexes is safe; deleting columns or changing column types requires first adding the new column → migrating the data → then deleting the old column. Use php artisan migrate --force to automate this in CI; if the migration fails, block the deployment.
Q What should I do if a Docker image is too large?
A Use the Alpine base image, multi-stage builds (without copying node_modules/.git), run composer install with --no-dev, and use .dockerignore to exclude test files. Target size: < 200MB.
Q How does the CI/CD pipeline handle rollbacks?
A We retain the most recent 5 versions of the Docker images; to roll back, simply combine docker compose pull <previous-version> and docker compose up -d. Rollbacks are faster with Blue-Green deployment—just switch back to the old slot.
Q Will queue:restart cause tasks to be lost?
A No. queue:restart allows the Worker to gracefully exit after completing its current task, and the Supervisor automatically restarts a new Worker. Tasks that haven't started yet remain in the queue, waiting to be processed by the new Worker.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Set up a Docker Compose development environment for ShopMetrics (app + MySQL + Redis + Mailpit) and ensure that the application is accessible directly after running docker compose up -d.

  2. Advanced Exercise (⭐⭐): Write a GitHub Actions workflow that automatically performs the following when the main branch is pushed: run tests → build a Docker image → push to the registry → deploy to the server via SSH → perform a health check. Include blocking logic for test failures and health check failures.

  3. Challenge (⭐⭐⭐): Implement a complete Blue-Green zero-downtime deployment solution: two sets of Docker Compose configurations, automatic detection of active slots, deployment to inactive slots, health checks, Nginx upstream switching, and graceful shutdown of old slots. Write a rollback script that can revert to the previous version within 30 seconds.

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%

🙏 帮我们做得更好

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

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