Laravel Deployment SOP¶
Version: 1.0
Owner: DevOps Team
Architecture¶
User → Host Nginx (80/443) → PHP-FPM container (9000) → MariaDB container (3306)
→ Redis container (6379) [optional]
Prerequisites¶
- Docker and Docker Compose installed
- Nginx installed on host (
apt install nginx) - Node.js 18+ and Composer on deployment host
- Domain DNS pointing to server
- GitHub repository with Actions enabled
Procedure¶
1. Standard Project Structure¶
Laravel Project Root (Git Repository)¶
laravel-app/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── tests/
├── Dockerfile # PHP-FPM image with Composer + Node
├── docker-compose.yml # App + MariaDB + Redis
├── .env.example # Environment template
├── .github/
│ └── workflows/
│ └── deploy.yml # GitHub Actions deploy
├── nginx/
│ └── app.conf # Nginx config template
└── supervisor/
└── laravel-worker.conf # Queue worker config
Server Deployment Directory¶
/opt/laravel/
├── html/ # Laravel application files (from git)
│ ├── public/
│ ├── storage/
│ ├── ...
│ ├── Dockerfile
│ └── docker-compose.yml
├── db/ # MariaDB data volume
├── redis/ # Redis data volume
├── .env # Production environment file
└── nginx/
└── app.conf # (optional) Nginx config backup
2. Create Dockerfile¶
Dockerfile
FROM php:8.2-fpm AS base
# System dependencies
RUN apt update && apt install -y \
git curl libpng-dev libjpeg-dev libfreetype6-dev \
libonig-dev libxml2-dev libzip-dev zip unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy composer files first (layer caching)
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy application
COPY . .
# Build frontend
RUN --mount=type=bind,from=node:18,source=/usr/local,target=/usr/local \
npm ci && npm run build
# Permissions
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]
# ----- Production stage (multi-stage) -----
FROM base AS production
RUN php artisan optimize \
&& php artisan route:cache \
&& php artisan view:cache \
&& php artisan config:cache
USER www-data
3. Create Docker Compose File¶
docker-compose.yml
services:
app:
image: laravel-app:latest
container_name: laravel-app
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
ports:
- "127.0.0.1:9000:9000"
volumes:
- ./html:/var/www/html
env_file: .env
depends_on:
maria-db:
condition: service_healthy
networks:
- laravel-network
maria-db:
image: mariadb:10
container_name: laravel-db
restart: unless-stopped
env_file: .env
volumes:
- ./db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
networks:
- laravel-network
redis:
image: redis:alpine
container_name: laravel-redis
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- ./redis:/data
networks:
- laravel-network
networks:
laravel-network:
driver: bridge
3. Create Dockerfile¶
Dockerfile
FROM php:8.2-fpm
# System dependencies
RUN apt update && apt install -y \
git curl libpng-dev libjpeg-dev libfreetype6-dev \
libonig-dev libxml2-dev libzip-dev zip unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Node.js for frontend build
COPY --from=node:18 /usr/local/bin/node /usr/local/bin/node
COPY --from=node:18 /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader \
&& npm ci && npm run build \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]
4. Create Environment File¶
.env
APP_NAME=Laravel
APP_ENV=production
APP_KEY=base64:your-generated-key
APP_DEBUG=false
APP_URL=https://example.com
DB_CONNECTION=mysql
DB_HOST=maria-db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel_user
DB_PASSWORD=strong_password
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=null
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
Generate APP_KEY
Run php artisan key:generate --show locally and paste the output into .env.
5. Configure Nginx on Host¶
/etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
root /opt/laravel/html/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.ht {
deny all;
}
}
Enable site:
ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
6. SSL Certificate¶
certbot --nginx -d example.com --non-interactive --agree-tos -m admin@example.com
7. Build & Start¶
# Build image
docker compose build
# Start containers
docker compose up -d
# Check status
docker compose ps
Expected output:
NAME IMAGE STATUS PORTS
laravel-app laravel-app:latest Up 127.0.0.1:9000->9000/tcp
laravel-db mariadb:10 Up 3306/tcp
laravel-redis redis:alpine Up 127.0.0.1:6379->6379/tcp
8. Run Migrations¶
docker compose exec app php artisan migrate --force
docker compose exec app php artisan storage:link
9. Post-Deployment Verification¶
# Health check
curl -I https://example.com
# Laravel-specific
docker compose exec app php artisan about
# Check logs
docker compose logs app --tail=50
tail -f /var/log/nginx/error.log
GitHub Actions Deployment¶
Repository Secrets¶
Add these in GitHub → Settings → Secrets:
| Secret | Value |
|---|---|
DEPLOY_HOST |
Server IP |
DEPLOY_USER |
SSH user |
SSH_PRIVATE_KEY |
Private SSH key |
APP_KEY |
Laravel APP_KEY |
Workflow File¶
.github/workflows/deploy.yml
name: Deploy Laravel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/laravel
# Pull latest code
git pull origin main
# Copy .env (managed separately)
cp .env.production .env
# Build new image
docker compose build
# Run migrations (before switch)
docker compose run --rm app php artisan migrate --force
# Recreate containers
docker compose up -d --force-recreate
# Cleanup old images
docker image prune -f
# Verify
curl -f https://example.com/health
Zero-Downtime Deploy (Alternative)¶
.github/workflows/deploy-zd.yml
name: Zero-Downtime Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t laravel-app:${{ github.sha }} .
- name: Push to registry
run: |
docker tag laravel-app:${{ github.sha }} ${{ secrets.REGISTRY }}/laravel-app:latest
docker push ${{ secrets.REGISTRY }}/laravel-app:latest
- name: Deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/laravel
# Pull new image
docker compose pull app
# Migrate DB
docker compose run --rm app php artisan migrate --force
# Recreate app only (no downtime for other services)
docker compose up -d --no-deps app
Maintenance¶
Artisan Commands¶
# Cache
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
# Clear cache (after updates)
docker compose exec app php artisan optimize:clear
# Queue worker
docker compose exec app php artisan queue:work --daemon
# Schedule (add to cron)
echo "* * * * * docker compose exec app php artisan schedule:run >> /dev/null 2>&1" | crontab -
Backup¶
# Database
docker compose exec maria-db mysqldump -u laravel_user -p laravel > backup-$(date +%Y%m%d).sql
# Files (uploads, logs)
tar czf storage-$(date +%Y%m%d).tar.gz html/storage
Update¶
git pull origin main
docker compose build app
docker compose run --rm app php artisan migrate --force
docker compose up -d --force-recreate app
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | PHP-FPM not running | docker compose restart app |
| 403 Forbidden | Wrong root path | Ensure root points to /opt/laravel/html/public |
| "No application encryption key" | APP_KEY missing | Run php artisan key:generate and update .env |
| Class not found | Composer autoload stale | docker compose exec app composer dump-autoload |
| SQLSTATE[HY000] [2002] | DB container not ready | Wait for healthcheck, or increase depends_on retries |
| npm/node not found | Build deps missing | Check Dockerfile installs Node |
Verification¶
- App image built and pushed
- Containers running (
docker compose ps) - Nginx proxies to FPM on
127.0.0.1:9000 - SSL certificate valid
- Migrations run
- Cache optimized (config, route, view)
- Queue worker running
- Scheduler cron set up
- GitHub Actions deploy succeeds
- Health endpoint responds 200