Beyond the Basics: Implementing Advanced CI/CD Pipelines for Laravel with Docker, GitHub Actions, and AWS ECS
Dockerizing the Laravel Application
The foundation of our advanced CI/CD pipeline is a robust Docker setup for the Laravel application. This ensures consistency across development, staging, and production environments. We’ll define a multi-stage Dockerfile to optimize image size and security.
First, let’s define the base PHP image with necessary extensions. We’ll use an official PHP image and install Composer, Node.js for asset compilation, and common PHP extensions.
# Stage 1: Builder
FROM php:8.2-fpm AS builder
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
zip \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip exif pcntl opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Install Node.js and npm (for asset compilation)
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm@latest
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY . .
# Install Composer dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Compile frontend assets
RUN npm install && npm run build
# Stage 2: Production
FROM php:8.2-fpm-alpine
# Install system dependencies for production
RUN apk add --no-cache \
libzip \
freetype \
libjpeg-turbo \
libpng \
oniguruma \
libxml2
# Install PHP extensions
RUN docker-php-ext-install -j$(nproc) pdo pdo_mysql zip exif pcntl opcache
# Copy application files from builder stage
COPY --from=builder /var/www/html /var/www/html
# Copy compiled assets from builder stage
COPY --from=builder /var/www/html/public/build /var/www/html/public/build
# Set permissions for storage and bootstrap/cache
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
# Expose port
EXPOSE 9000
# Set entrypoint (optional, can be overridden by ECS task definition)
ENTRYPOINT ["php-fpm"]
We also need a separate Dockerfile for Nginx, which will serve our Laravel application.
FROM nginx:alpine # Remove default Nginx configuration RUN rm /etc/nginx/conf.d/default.conf # Copy custom Nginx configuration COPY nginx.conf /etc/nginx/conf.d/default.conf # Copy application files (or mount them later) # COPY --from=builder /var/www/html /var/www/html # Expose port EXPOSE 80
And the corresponding Nginx configuration file (nginx.conf):
server {
listen 80;
index index.php index.html index.htm;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
root /var/www/html/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php-fpm:9000; # Assuming php-fpm service is available
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
# Serve static assets directly
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp)$ {
expires 1y;
log_not_found off;
}
}
Orchestration with AWS ECS and Fargate
AWS Elastic Container Service (ECS) with Fargate provides a serverless compute engine for containers. This abstracts away the underlying infrastructure, allowing us to focus on deploying and scaling our application.
We’ll define an ECS Task Definition that specifies the Docker images to use, CPU and memory requirements, environment variables, and port mappings. This definition will be used by GitHub Actions to launch new tasks.
{
"family": "laravel-app",
"networkMode": "awsvpc",
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "1024",
"memory": "2048",
"executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskRole",
"containerDefinitions": [
{
"name": "laravel-app-php",
"image": "YOUR_ECR_REPO_URI:latest",
"essential": true,
"portMappings": [
{
"containerPort": 9000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "APP_ENV",
"value": "production"
},
{
"name": "APP_URL",
"value": "https://your-domain.com"
},
{
"name": "DB_HOST",
"value": "your-db-host"
},
{
"name": "DB_PORT",
"value": "3306"
},
{
"name": "DB_DATABASE",
"value": "your-db-name"
},
{
"name": "DB_USERNAME",
"value": "your-db-user"
},
{
"name": "DB_PASSWORD",
"value": "your-db-password"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/laravel-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "php"
}
}
},
{
"name": "laravel-app-nginx",
"image": "YOUR_ECR_REPO_URI_NGINX:latest",
"essential": true,
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"links": [
"laravel-app-php"
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/laravel-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "nginx"
}
}
}
]
}
Important Notes:
- Replace
YOUR_ACCOUNT_ID,YOUR_ECR_REPO_URI,YOUR_ECR_REPO_URI_NGINX, and database credentials with your actual values. - Ensure the IAM roles
ecsTaskExecutionRoleandecsTaskRoleare properly configured with necessary permissions (e.g., ECR pull, CloudWatch Logs, Secrets Manager if used). - The
linksdirective is for older ECS configurations; for newer ones, use service discovery or task networking. In this Fargate example, we’ll rely on the default VPC networking and assume the Nginx container can reach the PHP-FPM container via its service name (e.g.,php-fpmif using ECS Service Discovery or a task definition where containers share the same network namespace). For simplicity in this example, we’ll assume the Nginx container can reach the PHP-FPM container by its container namelaravel-app-phpif they are in the same task. - Environment variables for sensitive data (like database passwords) should ideally be managed via AWS Secrets Manager or Parameter Store and injected into the task definition.
Automating with GitHub Actions
GitHub Actions will be our CI/CD orchestrator. We’ll create workflows to build Docker images, push them to Amazon Elastic Container Registry (ECR), and deploy to AWS ECS.
First, set up AWS credentials as a GitHub Secret (e.g., AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY). Also, configure ECR repository URIs and ECS cluster/service names as secrets (e.g., AWS_ECR_REPO_URI, AWS_ECS_CLUSTER_NAME, AWS_ECS_SERVICE_NAME).
Here’s a workflow for building and pushing Docker images:
name: Build and Push Docker Images
on:
push:
branches:
- main # Or your primary development branch
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1 # Your AWS region
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push Laravel PHP image
id: build-php
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_URI_PHP }} # e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com/laravel-app-php
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -f Dockerfile -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
- name: Build and push Nginx image
id: build-nginx
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_URI_NGINX }} # e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com/laravel-app-nginx
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -f Dockerfile.nginx -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
Next, a workflow to deploy to ECS. This workflow will trigger after a successful build and push.
name: Deploy to AWS ECS
on:
push:
branches:
- main # Trigger deployment on push to main
jobs:
deploy:
runs-on: ubuntu-latest
needs: build-and-push # Ensure build is complete
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1 # Your AWS region
- name: Get ECR image tags
id: image-tags
run: |
echo "::set-output name=php_image::${{ secrets.AWS_ECR_REPO_URI_PHP }}:${{ github.sha }}"
echo "::set-output name=nginx_image::${{ secrets.AWS_ECR_REPO_URI_NGINX }}:${{ github.sha }}"
- name: Update ECS Task Definition
id: update-task-def
uses: aws-actions/amazon-ecs-update-task-definition@v1
with:
cluster-name: ${{ secrets.AWS_ECS_CLUSTER_NAME }}
service-name: ${{ secrets.AWS_ECS_SERVICE_NAME }}
task-definition: task-definition.json # Path to your task definition file
container-name-php: laravel-app-php # Name of the PHP container in task-def
container-name-nginx: laravel-app-nginx # Name of the Nginx container in task-def
image-php: ${{ steps.image-tags.outputs.php_image }}
image-nginx: ${{ steps.image-tags.outputs.nginx_image }}
- name: Deploy to Amazon ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
cluster-name: ${{ secrets.AWS_ECS_CLUSTER_NAME }}
service-name: ${{ secrets.AWS_ECS_SERVICE_NAME }}
task-definition: ${{ steps.update-task-def.outputs.task-definition }}
wait-for-service-stability: true
In the deployment workflow, we use the amazon-ecs-update-task-definition action to dynamically update the image tags in our task-definition.json file with the newly built images from ECR. Then, amazon-ecs-deploy-task-definition registers the new task definition and updates the ECS service to use it, triggering a rolling deployment.
Database Migrations and Seeding
Handling database migrations in a containerized, automated deployment requires careful consideration. We don’t want migrations to run on every container start, nor do we want them to conflict if multiple instances start simultaneously.
A common strategy is to run migrations as a separate, one-off task before deploying the new application version. This can be integrated into the deployment workflow.
We can create a separate Docker image or a command within the existing workflow to execute migrations. For simplicity, let’s assume we’ll run migrations using the existing Laravel PHP image as a one-off task.
# Add this job to your deploy workflow
run-migrations:
runs-on: ubuntu-latest
needs: build-and-push # Ensure build is complete
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Set up PHP and Composer
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, ctype, tokenizer, json, dom, fileinfo, redis
coverage: none
- name: Install Composer dependencies
run: |
composer install --no-dev --optimize-autoloader --no-interaction
- name: Run Laravel Migrations
env:
APP_ENV: production
APP_URL: https://your-domain.com
DB_HOST: your-db-host
DB_PORT: 3306
DB_DATABASE: your-db-name
DB_USERNAME: your-db-user
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Use a secret for DB password
REDIS_HOST: your-redis-host # Example for Redis
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} # Example for Redis
run: |
php artisan migrate --force
# Optionally run seeding
# php artisan db:seed --force
This migration job should run before the deployment job updates the ECS service. The --force flag is necessary for production environments to bypass the confirmation prompt. It’s crucial to manage the database password securely using GitHub Secrets.
Environment Management and Secrets
Managing environments (development, staging, production) and sensitive credentials is a critical aspect of any CI/CD pipeline. For this setup, we’ll leverage GitHub Actions secrets and AWS services.
GitHub Secrets:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY: For authenticating with AWS.AWS_ECR_REPO_URI_PHP,AWS_ECR_REPO_URI_NGINX: ECR repository URIs.AWS_ECS_CLUSTER_NAME,AWS_ECS_SERVICE_NAME: ECS cluster and service identifiers.DB_PASSWORD,REDIS_PASSWORD(and any other sensitive credentials): For database and other service connections.
AWS Secrets Manager / Parameter Store:
For production, it’s highly recommended to store sensitive information like database passwords, API keys, and JWT secrets in AWS Secrets Manager or AWS Systems Manager Parameter Store. The ECS task definition can then be configured to inject these secrets as environment variables into the containers.
{
"family": "laravel-app",
"networkMode": "awsvpc",
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "1024",
"memory": "2048",
"executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskRole",
"containerDefinitions": [
{
"name": "laravel-app-php",
"image": "YOUR_ECR_REPO_URI:latest",
"essential": true,
"portMappings": [
{
"containerPort": 9000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "APP_ENV",
"value": "production"
},
{
"name": "APP_URL",
"value": "https://your-domain.com"
}
],
"secrets": [
{
"name": "DB_HOST",
"valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:your-db-host-secret-xxxxx"
},
{
"name": "DB_PORT",
"valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:your-db-port-secret-xxxxx"
},
{
"name": "DB_DATABASE",
"valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:your-db-name-secret-xxxxx"
},
{
"name": "DB_USERNAME",
"valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:your-db-user-secret-xxxxx"
},
{
"name": "DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:your-db-password-secret-xxxxx"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/laravel-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "php"
}
}
},
// ... Nginx container definition
]
}
The taskRoleArn must have permissions to access Secrets Manager (e.g., secretsmanager:GetSecretValue). The application code in Laravel will then access these values as standard environment variables.
Monitoring and Logging
Effective monitoring and logging are crucial for maintaining a healthy production environment. Our setup leverages AWS CloudWatch for centralized logging.
As configured in the ECS task definition, we are using the awslogs log driver. This automatically streams container logs to CloudWatch Logs. You can then create log groups and streams for your Laravel application and Nginx containers.
Within CloudWatch Logs, you can:
- View real-time logs from your containers.
- Create metric filters to track specific events (e.g., errors, request counts).
- Set up alarms based on these metrics to notify you of potential issues.
- Use CloudWatch Logs Insights for advanced log querying and analysis.
For application performance monitoring (APM), consider integrating tools like AWS X-Ray, Datadog, or New Relic. These can provide deeper insights into application performance, trace requests across services, and help pinpoint bottlenecks.
Additionally, set up health checks within your ECS service. This allows ECS to automatically detect unhealthy tasks and replace them, ensuring high availability.