Automate Laravel Deployment to AWS ECS with AWS CDK and CodePipeline

In the first part of this tutorial series, we manually deployed a PHP (Laravel) application to Amazon ECS. In this second part, we'll automate the entire deployment process using AWS Cloud Development Kit (CDK), CodePipeline, and CodeBuild. This will enable continuous integration and continuous deployment (CI/CD) for your application, ensuring that every code change is automatically built, tested, and deployed.

Prerequisites

Before we begin, make sure you have the following:

  • AWS Account: An AWS account with permissions to create resources like IAM roles, ECS clusters, and CodePipeline.
  • AWS CLI: Installed and configured with your AWS credentials.
  • AWS CDK: Installed globally using npm install -g aws-cdk.
  • Git Repository: Your Laravel application's code pushed to a Git repository (e.g., GitHub, CodeCommit).
  • Docker: Installed and running on your development machine.

Overview

We'll accomplish the following steps:

  1. Set Up AWS CDK Project: Initialize a new CDK project in TypeScript.
  2. Define Infrastructure with CDK: Use CDK to define AWS resources like ECR repository, ECS cluster, task definitions, and services.
  3. Set Up CodeBuild Project: Configure CodeBuild to build your Docker image and push it to ECR.
  4. Set Up CodePipeline: Create a pipeline that triggers on code commits, builds the Docker image, and deploys it to ECS.
  5. Deploy CDK Stack: Deploy the CDK stack to create the resources in your AWS account.
  6. Test the Deployment: Verify that your application is automatically deployed upon code changes.

Step 1: Set Up AWS CDK Project

Initialize a New CDK Project

Create a new directory for your CDK project and initialize it:

mkdir laravel-ecs-cdk
cd laravel-ecs-cdk
cdk init app --language typescript

This will generate a basic CDK project structure with TypeScript.

Install Required CDK Libraries

Install the necessary CDK libraries for ECS, ECR, CodePipeline, and CodeBuild:

npm install @aws-cdk/aws-ecs @aws-cdk/aws-ecr @aws-cdk/aws-ec2 @aws-cdk/aws-iam @aws-cdk/aws-codepipeline @aws-cdk/aws-codepipeline-actions @aws-cdk/aws-codebuild @aws-cdk/aws-elasticloadbalancingv2

Step 2: Define Infrastructure with CDK

Open the lib/laravel-ecs-cdk-stack.ts file and start defining your infrastructure.

Import Required Modules

At the top of your stack file, import the required modules:

import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as ecs from '@aws-cdk/aws-ecs';
import * as ecr from '@aws-cdk/aws-ecr';
import * as iam from '@aws-cdk/aws-iam';
import * as codepipeline from '@aws-cdk/aws-codepipeline';
import * as codepipeline_actions from '@aws-cdk/aws-codepipeline-actions';
import * as codebuild from '@aws-cdk/aws-codebuild';
import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2';

Define VPC and ECS Cluster

Create a VPC and an ECS cluster within it:

export class LaravelEcsCdkStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Create VPC with public and private subnets
    const vpc = new ec2.Vpc(this, 'LaravelVpc', {
      maxAzs: 2,
    });

    // Create ECS Cluster
    const cluster = new ecs.Cluster(this, 'LaravelCluster', {
      vpc: vpc,
    });
  }
}

Create ECR Repository

Define an ECR repository to store your Docker images:

    // Create ECR Repository
    const repository = new ecr.Repository(this, 'LaravelRepository', {
      repositoryName: 'laravel-app',
    });

Define IAM Roles

Create an IAM role for CodeBuild to access AWS services:

    // IAM Role for CodeBuild
    const codeBuildRole = new iam.Role(this, 'CodeBuildRole', {
      assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'),
    });

    // Grant permissions to CodeBuild Role
    repository.grantPullPush(codeBuildRole);

Define ECS Task Definition and Service

Set up the ECS task definition and service:

    // Define Task Role and Execution Role
    const taskExecutionRole = new iam.Role(this, 'TaskExecutionRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'),
      ],
    });

    // Define Task Definition
    const taskDefinition = new ecs.FargateTaskDefinition(this, 'LaravelTaskDef', {
      memoryLimitMiB: 512,
      cpu: 256,
      executionRole: taskExecutionRole,
    });

    // Add Container to Task Definition
    const container = taskDefinition.addContainer('LaravelContainer', {
      image: ecs.ContainerImage.fromEcrRepository(repository),
      logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'LaravelApp' }),
    });

    // Expose Port 80
    container.addPortMappings({
      containerPort: 80,
    });

    // Create Security Group for the Service
    const serviceSecurityGroup = new ec2.SecurityGroup(this, 'ServiceSecurityGroup', {
      vpc,
      description: 'Allow HTTP traffic',
      allowAllOutbound: true,
    });
    serviceSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP');

    // Create Fargate Service
    const service = new ecs.FargateService(this, 'LaravelService', {
      cluster,
      taskDefinition,
      desiredCount: 1,
      securityGroups: [serviceSecurityGroup],
    });

Configure Application Load Balancer

Set up an Application Load Balancer to distribute traffic:

    // Create ALB
    const lb = new elbv2.ApplicationLoadBalancer(this, 'LaravelALB', {
      vpc,
      internetFacing: true,
    });

    // Add Listener on Port 80
    const listener = lb.addListener('PublicListener', {
      port: 80,
      open: true,
    });

    // Attach the Service to the Load Balancer
    listener.addTargets('LaravelTarget', {
      port: 80,
      targets: [service],
      healthCheck: {
        path: '/',
        interval: cdk.Duration.seconds(60),
      },
    });

    // Output the Load Balancer DNS
    new cdk.CfnOutput(this, 'LoadBalancerDNS', {
      value: lb.loadBalancerDnsName,
    });

Step 3: Set Up CodeBuild Project

Create a CodeBuild project to build your Docker image:

    // Define CodeBuild Project
    const codeBuildProject = new codebuild.PipelineProject(this, 'CodeBuildProject', {
      role: codeBuildRole,
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        privileged: true, // Required to build Docker images
      },
      environmentVariables: {
        'REPOSITORY_URI': { value: repository.repositoryUri },
      },
      buildSpec: codebuild.BuildSpec.fromObject({
        version: '0.2',
        phases: {
          pre_build: {
            commands: [
              'echo Logging in to Amazon ECR...',
              'aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $REPOSITORY_URI',
            ],
          },
          build: {
            commands: [
              'echo Build started on `date`',
              'echo Building the Docker image...',
              'docker build -t $REPOSITORY_URI:latest .',
              'docker push $REPOSITORY_URI:latest',
            ],
          },
          post_build: {
            commands: [
              'echo Build completed on `date`',
            ],
          },
        },
      }),
    });

Step 4: Set Up CodePipeline

Create a CodePipeline to automate the build and deployment process.

Define Source Artifact

Specify the source of your code:

    // Source Artifact
    const sourceOutput = new codepipeline.Artifact();

Configure Source Action

Set up the source action to pull code from your Git repository. For example, if you're using GitHub:

    // GitHub Source Action
    const sourceAction = new codepipeline_actions.GitHubSourceAction({
      actionName: 'GitHub_Source',
      owner: 'your-github-username',
      repo: 'your-laravel-repo',
      oauthToken: cdk.SecretValue.secretsManager('github-token'),
      output: sourceOutput,
      branch: 'main',
    });

Note: You need to store your GitHub OAuth token in AWS Secrets Manager with the name github-token.

Define Build Artifact

Specify the build output:

    // Build Artifact
    const buildOutput = new codepipeline.Artifact();

Configure Build Action

Set up the build action:

    // CodeBuild Action
    const buildAction = new codepipeline_actions.CodeBuildAction({
      actionName: 'CodeBuild',
      project: codeBuildProject,
      input: sourceOutput,
      outputs: [buildOutput],
    });

Update ECS Service on Deployment

Create an action to update the ECS service when a new image is pushed:

    // Deploy Action
    const deployAction = new codepipeline_actions.EcsDeployAction({
      actionName: 'DeployAction',
      service: service,
      input: buildOutput,
    });

Create the Pipeline

Assemble the pipeline:

    // Define Pipeline
    const pipeline = new codepipeline.Pipeline(this, 'LaravelPipeline', {
      pipelineName: 'LaravelPipeline',
      restartExecutionOnUpdate: true,
    });

    // Add Stages to Pipeline
    pipeline.addStage({
      stageName: 'Source',
      actions: [sourceAction],
    });

    pipeline.addStage({
      stageName: 'Build',
      actions: [buildAction],
    });

    pipeline.addStage({
      stageName: 'Deploy',
      actions: [deployAction],
    });

Step 5: Deploy CDK Stack

Bootstrap CDK

If you haven't bootstrapped your AWS environment for CDK, do it now:

cdk bootstrap aws://ACCOUNT_ID/REGION

Replace ACCOUNT_ID and REGION with your AWS account ID and desired region.

Deploy the Stack

Deploy the CDK stack:

cdk deploy

This command will list the resources to be created and ask for confirmation. Type y to proceed.

Step 6: Test the Deployment

Push Code Changes

Make a code change in your Laravel application and push it to your Git repository's main branch.

Verify CodePipeline Execution

Navigate to the AWS CodePipeline console and select your pipeline. You should see a new execution triggered by your code change.

Check CodeBuild Logs

In the CodePipeline execution, click on the CodeBuild action to view the build logs. Ensure that the build succeeds and the Docker image is pushed to ECR.

Verify ECS Service Update

Once the deployment stage is reached, the ECS service should automatically update with the new Docker image.

Access Your Application

Obtain the DNS name of your Application Load Balancer from the CloudFormation outputs or the AWS console. Visit the URL in your browser to see your updated application.

Additional Configurations

HTTPS Support

For HTTPS support, you need to:

  • Obtain an SSL/TLS Certificate: Use AWS Certificate Manager (ACM) to get a certificate for your domain.
  • Update the Load Balancer Listener: Modify the listener to use port 443 and attach the SSL certificate.
  • Adjust Security Groups: Allow inbound traffic on port 443.

Environment Variables

If your Laravel application requires environment variables, you can pass them to the container in the task definition:

    container.addEnvironment('APP_ENV', 'production');
    container.addEnvironment('APP_KEY', 'your-app-key');

Consider using AWS Secrets Manager or SSM Parameter Store for sensitive information.

Persistent Storage with EFS

To handle Laravel's storage requirements, you can attach an EFS volume to your task definition:

    // Create EFS File System
    const fileSystem = new efs.FileSystem(this, 'FileSystem', {
      vpc,
      encrypted: true,
    });

    // Add Volume to Task Definition
    taskDefinition.addVolume({
      name: 'LaravelStorage',
      efsVolumeConfiguration: {
        fileSystemId: fileSystem.fileSystemId,
      },
    });

    // Mount Point
    container.addMountPoints({
      containerPath: '/var/www/storage',
      sourceVolume: 'LaravelStorage',
      readOnly: false,
    });

Conclusion

You've successfully automated the deployment of your Laravel application to AWS ECS using AWS CDK, CodePipeline, and CodeBuild. With this setup:

  • Continuous Integration: Every code change is automatically built and tested.
  • Continuous Deployment: Successful builds are automatically deployed to ECS.
  • Infrastructure as Code: Your infrastructure is defined in code, making it repeatable and version-controlled.

Next Steps (we'll leave these for another tutorial)

  • Monitoring and Logging: Integrate AWS CloudWatch for enhanced monitoring.
  • Scaling: Configure auto-scaling policies for your ECS service.
  • Testing: Add automated tests to your CodeBuild project.

Congratulations! You've taken a significant step towards modernizing your application's deployment process.