Laravel on AWS: a reference architecture

Posted on: May 16, 2025 11:37 AM

Posted by: Renato

Categories: Architecture aws Laravel

Views: 399

Laravel on AWS: a reference architecture

A guide to networking, security, autoscaling and high-availability

It’s not an easy task to set up durable architecture for your web application. And if you try to build it as you go, you’ll soon get tired of clicking around the AWS console. What if you had one go-to architecture and repeatable process for all your projects, while ensuring maximum security, performance and availability? Here is how you should deploy your Laravel application on AWS.

Lionel is Chief Technology Officer of London-based startup Wi5 and author of the Future-Proof Engineering Culture course. You can reach out to him on https://getlionel.com

How we will enforce security:
— Create VPC subnets to deploy our application into. A VPC is your own virtual network within AWS and lets you design private subnets where instances can’t be accessed directly from outside your VPC. This is where we will deploy our web and database instances.
— Use temporary bastions (also called jump boxes) that we will deploy in our public subnets when we need to connect to web and database instances, reducing the surface of attack
— Enforce firewalls rules by whitelisting which servers can talk to each other, using VPC security groups (SGs). SGs are default-deny stateful firewalls applied at the instance level.
— Simplify secret management by avoiding passwords where possible and instead specifying IAM roles to control access to our resources. Using IAM roles for EC2 removes the need to store AWS credentials in a configuration file. Roles use temporary security tokens under the hood which AWS takes care of rotating so we don’t have to worry about updating passwords.

How we will enforce high availability:
— Span our application instances across Availability Zones (AZs below). An AZ is one or more data centers within a region that are designed to be isolated from failures in other AZs. By placing resources in separate AZs, organisations can protect their application from a service disruption impacting a single location
— Serve our application from an Elastic Load Balancer. ELB is a highly available (distributed) service that distributes traffic across a group of EC2 instances in one or more AZs. ELB supports health checks to ensure traffic is not routed to unhealthy or failing instances
— Host our application on ECS, describing through ECS services what minimum number of healthy application containers should be running at any given time. ECS services will start new containers if one ever crashes.
— Distribute our database as a cluster across multiple AZs. RDS allows you to place a secondary copy of your database in another AZ for disaster recovery purposes. You are assigned a database endpoint in the form of a DNS name that AWS takes responsibility for resolving to a specific IP address. RDS will automatically fail over to the standby instance without user intervention.
Preferably we will be using Amazon Aurora, which will maintain a read replica of our database in a separate AZ and that Amazon will promote as the primary instance should our main instance (or its AZ) fail.
— Finally, we rely on as many distributed services as possible to delegate failure management to AWS: services like S3, SQS, ELB/ALB, ECR and CloudWatch are designed for maximum resiliency without us having to care for the instances they run on.

Laravel, made highly available with almost a one-click deploy!

How we will build ourselves a repeatable process:
We will be deploying an empty Laravel application on a fresh domain name using Docker, CloudFormation and the AWS CLI. CloudFormation defines a templating language that can be used to describe all the AWS resources that are necessary for a workload. Templates are submitted to CloudFormation and the service will provision and configure those resources in appropriate order.
Docker container images are stand-alone, executable packages of a piece of software that include everything needed to run it.
With the AWS CLI, you can control all services from the command line and automate them through scripts.
By combining all three, both our infrastructure and our application configuration can be written as code and as such be versioned, branched, documented.

This is the procedure I use to deploy my clients’ Laravel applications on AWS. I hope this can be helpful to deploy yours. If your use case is more complex, I provide on-going support packages ranging from mentoring your developers up to hands-on building your application on AWS. Ping me at [email protected]

Let’s do it step by step:
1. Set up your AWS credentials
Start with authenticating your command line by downloading the API key and secret for a new user in the IAM section of your AWS console. This user will need to have to have permissions to create resources for all the services we will use below. Follow the prompts from:

aws configure
Use the profile option to save different credentials for different projects

2. Order SSL certificates
We need two certificates: one for our web application itself and another one for our custom domain on CloudFront. The one for your web application needs to be created in the AWS region you want to deploy your application into whereas CloudFront will only accept certificates generated in region us-east-1.
AWS SSL/TLS certificates are free, automatically provisioned and renewed, even if you did not buy your domain in Route53. They seamlessly integrate with AWS load balancers, CloudFront distributions and API Gateway endpoints so you can just set them and forget them.

                              
                                 # a certificate in your default region for your web application - certificates.sh
                                 aws acm request-certificate
                                    --domain-name laravelaws.com
                                    --idempotency-token=random_string_here
                                    --subject-alternative-names *.laravelaws.com

                                 # a certificate from us-east-1 specifically for our CloudFront custom domain
                                 aws --region us-east-1 acm request-certificate
                                    --domain-name laravelaws.com
                                    --idempotency-token=random_string_here
                                    --subject-alternative-names *.laravelaws.com
                                 
                              

3. Create a key pair to be used by your EC2 instances
It is recommended to create a new SSH key pair for all EC2 instances of this new project, still using the CLI:

                            
                              
                              # Create the key pair and extract the private key from the JSON response gistfile1.sh
                                 aws ec2 create-key-pair
                                    --key-name=laravelaws
                                    --query 'KeyMaterial'
                                    --output text > laravelaws.pem

                                 # Assign appropriate permissions to the key file for it to be usable
                                 chmod 400 laravelaws.pem
                                 
                              

Remember that AWS won’t store SSH keys for you and you are responsible for storing and sharing them securely.

4. Launch our CloudFormation stacks
Here comes the infrastructure-as-code! Our whole deployment will be described in one master YAML template, itself referencing nested stacks YAML templates to make it more readable and reusable.
This is the directory structure of our templates:

                           ├── master.yaml                # the root template
                           ├── infrastructure
                           ├── vpc.yaml                 # our VPC and security groups
                           ├── storage.yaml             # our database cluster and S3 bucket
                           ├── web.yaml                   # our ECS cluster
                           └── services.yaml              # our ECS Tasks Definitions & Services
                        

And the complete code can be downloaded from GitHub here:

The vpc.yaml template defines our VPC subnets and route tables:

                                 
                                 # This template creates a VPC and a pair public and private subnets spanning the first two AZs of your current region. network.yaml 
                                 # Each instance in the public subnet can accessed the internet and be accessed from the internet
                                 # thanks to a route table routing traffic through the Internet Gateway.
                                 # Private subnets feature a NAT Gateway located in the public subnet of the same AZ, so they can receive traffic 
                                 # from within the VPC.
                                 VPC: 
                                    Type: AWS::EC2::VPC
                                    Properties:
                                       CidrBlock: !Ref VpcCIDR
                                       Tags: 
                                             - Key: Name 
                                             Value: !Ref EnvironmentName

                                 InternetGateway:
                                    Type: AWS::EC2::InternetGateway
                                    Properties:
                                       Tags:
                                             - Key: Name
                                             Value: !Ref EnvironmentName

                                 InternetGatewayAttachment:
                                    Type: AWS::EC2::VPCGatewayAttachment
                                    Properties:
                                       InternetGatewayId: !Ref InternetGateway
                                       VpcId: !Ref VPC

                                 PublicSubnet1: 
                                    Type: AWS::EC2::Subnet
                                    Properties:
                                       VpcId: !Ref VPC
                                       AvailabilityZone: !Select [ 0, !GetAZs ]
                                       CidrBlock: !Ref PublicSubnet1CIDR
                                       MapPublicIpOnLaunch: true
                                       Tags: 
                                             - Key: Name 
                                             Value: !Sub ${EnvironmentName} Public Subnet (AZ1)

                                 PublicSubnet2:
                                    Type: AWS::EC2::Subnet
                                    Properties:
                                       VpcId: !Ref VPC
                                       AvailabilityZone: !Select [ 1, !GetAZs ]
                                       CidrBlock: !Ref PublicSubnet2CIDR
                                       MapPublicIpOnLaunch: true
                                       Tags:
                                             - Key: Name
                                             Value: !Sub ${EnvironmentName} Public Subnet (AZ2)

                                 PrivateSubnet1:
                                    Type: AWS::EC2::Subnet
                                    Properties:
                                       VpcId: !Ref VPC
                                       AvailabilityZone: !Select [ 0, !GetAZs ]
                                       CidrBlock: !Ref PrivateSubnet1CIDR
                                       MapPublicIpOnLaunch: false
                                       Tags:
                                             - Key: Name
                                             Value: !Sub ${EnvironmentName} Private Subnet (AZ1)

                                 PrivateSubnet2:
                                    Type: AWS::EC2::Subnet
                                    Properties:
                                       VpcId: !Ref VPC
                                       AvailabilityZone: !Select [ 1, !GetAZs ]
                                       CidrBlock: !Ref PrivateSubnet2CIDR
                                       MapPublicIpOnLaunch: false
                                       Tags:
                                             - Key: Name
                                             Value: !Sub ${EnvironmentName} Private Subnet (AZ2)

                                 NatGateway1EIP:
                                    Type: AWS::EC2::EIP
                                    DependsOn: InternetGatewayAttachment
                                    Properties:
                                       Domain: vpc

                                 NatGateway2EIP:
                                    Type: AWS::EC2::EIP
                                    DependsOn: InternetGatewayAttachment
                                    Properties:
                                       Domain: vpc

                                 NatGateway1:
                                    Type: AWS::EC2::NatGateway
                                    Properties:
                                       AllocationId: !GetAtt NatGateway1EIP.AllocationId
                                       SubnetId: !Ref PublicSubnet1

                                 NatGateway2:
                                    Type: AWS::EC2::NatGateway
                                    Properties:
                                       AllocationId: !GetAtt NatGateway2EIP.AllocationId
                                       SubnetId: !Ref PublicSubnet2

                                 PublicRouteTable:
                                    Type: AWS::EC2::RouteTable
                                    Properties: 
                                       VpcId: !Ref VPC
                                       Tags: 
                                             - Key: Name 
                                             Value: !Sub ${EnvironmentName} Public Routes

                                 DefaultPublicRoute: 
                                    Type: AWS::EC2::Route
                                    DependsOn: InternetGatewayAttachment
                                    Properties: 
                                       RouteTableId: !Ref PublicRouteTable
                                       DestinationCidrBlock: 0.0.0.0/0
                                       GatewayId: !Ref InternetGateway

                                 PublicSubnet1RouteTableAssociation:
                                    Type: AWS::EC2::SubnetRouteTableAssociation
                                    Properties:
                                       RouteTableId: !Ref PublicRouteTable
                                       SubnetId: !Ref PublicSubnet1

                                 PublicSubnet2RouteTableAssociation:
                                    Type: AWS::EC2::SubnetRouteTableAssociation
                                    Properties:
                                       RouteTableId: !Ref PublicRouteTable
                                       SubnetId: !Ref PublicSubnet2


                                 PrivateRouteTable1:
                                    Type: AWS::EC2::RouteTable
                                    Properties:
                                       VpcId: !Ref VPC
                                       Tags:
                                             - Key: Name
                                             Value: !Sub ${EnvironmentName} Private Routes (AZ1)

                                 DefaultPrivateRoute1:
                                 Type: AWS::EC2::Route
                                 Properties:
                                    RouteTableId: !Ref PrivateRouteTable1
                                    DestinationCidrBlock: 0.0.0.0/0
                                    NatGatewayId: !Ref NatGateway1

                                 PrivateSubnet1RouteTableAssociation:
                                 Type: AWS::EC2::SubnetRouteTableAssociation
                                 Properties:
                                    RouteTableId: !Ref PrivateRouteTable1
                                    SubnetId: !Ref PrivateSubnet1

                                 PrivateRouteTable2:
                                 Type: AWS::EC2::RouteTable
                                 Properties:
                                    VpcId: !Ref VPC
                                    Tags:
                                       - Key: Name
                                       Value: !Sub ${EnvironmentName} Private Routes (AZ2)

                                 DefaultPrivateRoute2:
                                 Type: AWS::EC2::Route
                                 Properties:
                                    RouteTableId: !Ref PrivateRouteTable2
                                    DestinationCidrBlock: 0.0.0.0/0
                                    NatGatewayId: !Ref NatGateway2

                                 PrivateSubnet2RouteTableAssociation:
                                 Type: AWS::EC2::SubnetRouteTableAssociation
                                 Properties:
                                    RouteTableId: !Ref PrivateRouteTable2
                                    SubnetId: !Ref PrivateSubnet2
                                 
                              

This is quite verbose and is everything it takes to set up public and private subnets spanning two AZs. You can see why you wouldn’t want to implement this in the AWS console!

We also need three SGs. The first one is to secure our EC2 instances and only allow inbound traffic coming from the load-balancer plus any SSH inbound traffic (remember our instances will be in a private subnet and won’t be able to receive traffic from the internet anyway):

                              
                                 # This security group defines who/where is allowed to access the ECS hosts directly. security-group-ecs.yml
                                 # By default we're just allowing access from the load balancer.  If you want to SSH
                                 # into the hosts, or expose non-load balanced services you can open their ports here.
                                 ECSSecurityGroup:
                                    Type: AWS::EC2::SecurityGroup
                                    Properties:
                                       VpcId: !Ref VPC
                                       GroupDescription: Access to the ECS hosts and the tasks/containers that run on them
                                       SecurityGroupIngress:
                                          # Only allow inbound access to ECS from the ELB
                                          - SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
                                             IpProtocol: -1
                                          - IpProtocol: tcp
                                             CidrIp: 0.0.0.0/0
                                             FromPort: '22'
                                             ToPort: '22'
                                       Tags:
                                          - Key: Name
                                             Value: !Sub ${EnvironmentName}-ECS-Hosts
                              
                              

The load balancer’s SG will allow any traffic from the internet (while only responding to HTTP and HTTPS):

                              
                              # This security group defines who/where is allowed to access the Application Load Balancer. load-balancer-security-group.yaml
                              # By default, we've opened this up to the public internet (0.0.0.0/0) but can you restrict
                              # it further if you want.
                              LoadBalancerSecurityGroup:
                                 Type: AWS::EC2::SecurityGroup
                                 Properties:
                                    VpcId: !Ref VPC
                                    GroupDescription: Access to the load balancer that sits in front of ECS
                                    SecurityGroupIngress:
                                       # Allow access from anywhere to our ECS services
                                       - CidrIp: 0.0.0.0/0
                                          IpProtocol: -1
                                    Tags:
                                       - Key: Name
                                          Value: !Sub ${EnvironmentName}-LoadBalancers
                              
                              

Finally, the database SG only allows ingress traffic on MySQL port and coming from our EC2 instances, and nothing from the internet. Our database will also be hosted inside our private subnets so it can’t receive any traffic from outside the VPC.


                              
                                                            # This security group defines who/where is allowed to access the RDS instance. database-security-group.yaml
                              # Only instances associated with our ECS security group can reach to the database endpoint.
                              DBSecurityGroup:
                              Type: AWS::EC2::SecurityGroup
                              Properties:
                                    GroupDescription: Open database for access
                                    VpcId: !Ref VPC
                                    SecurityGroupIngress:
                                       - IpProtocol: tcp
                                          FromPort: '3306'
                                          ToPort: '3306'
                                          SourceSecurityGroupId: !Ref ECSSecurityGroup
                                    Tags:
                                       - Key: Name
                                          Value: !Sub ${EnvironmentName}-DB-Host
                              
                              

Let’s now launch our storage.yaml stack:

                              
                              # I recommend to encrypt your database to make sure your snapshots and logs are encrypted too. storage.yaml 
                              # Automatic snapshots are stored by AWS itself, however manual snapshots will be stored in your S3 account.
                              # You don't want to accidentally open access to an unencrypted version of your data!
                              # It is also preferable not to use your default AWS master key if you ever need to transfer a snapshot to another
                              # AWS account later as you can't give cross-account access to your master key.
                              #
                              # Not that we only create one primary DB instance for now, no read replica.
                              KmsKey:
                                 Type: AWS::KMS::Key
                                 Properties:
                                    Description: !Sub KMS Key for our ${AWS::StackName} DB
                                    KeyPolicy:
                                          Id: !Ref AWS::StackName
                                          Version: "2012-10-17"
                                          Statement:
                                             -
                                                Sid: "Allow administration of the key"
                                                Effect: "Allow"
                                                Action:
                                                      - kms:Create*
                                                      - kms:Describe*
                                                      - kms:Enable*
                                                      - kms:List*
                                                      - kms:Put*
                                                      - kms:Update*
                                                      - kms:Revoke*
                                                      - kms:Disable*
                                                      - kms:Get*
                                                      - kms:Delete*
                                                      - kms:ScheduleKeyDeletion
                                                      - kms:CancelKeyDeletion
                                                Principal:
                                                      AWS: !Ref AWS::AccountId
                                                Resource: '*'
                                             -
                                                Sid: "Allow use of the key"
                                                Effect: "Allow"
                                                Principal:
                                                      AWS: !Ref AWS::AccountId
                                                Action:
                                                      - "kms:Encrypt"
                                                      - "kms:Decrypt"
                                                      - "kms:ReEncrypt*"
                                                      - "kms:GenerateDataKey*"
                                                      - "kms:DescribeKey"
                                                Resource: "*"

                              DatabaseSubnetGroup:
                                 Type: AWS::RDS::DBSubnetGroup
                                 Properties:
                                    DBSubnetGroupDescription: CloudFormation managed DB subnet group.
                                    SubnetIds: !Ref DatabaseSubnets

                              DatabaseCluster:
                                 Type: AWS::RDS::DBCluster
                                 Properties:
                                    Engine: aurora
                                    DatabaseName: !Ref DatabaseName
                                    MasterUsername: !Ref DatabaseUsername
                                    MasterUserPassword: !Ref DatabasePassword
                                    BackupRetentionPeriod: 7
                                    PreferredBackupWindow: 01:00-02:30
                                    PreferredMaintenanceWindow: mon:03:00-mon:04:00
                                    DBSubnetGroupName: !Ref DatabaseSubnetGroup
                                    KmsKeyId: !GetAtt KmsKey.Arn
                                    StorageEncrypted: true
                                    VpcSecurityGroupIds:
                                       - !Ref DatabaseSecurityGroup

                              DatabasePrimaryInstance:
                                 Type: AWS::RDS::DBInstance
                                 Properties:
                                    Engine: aurora
                                    DBClusterIdentifier: !Ref DatabaseCluster
                                    DBInstanceClass: !Ref DatabaseInstanceType
                                    DBSubnetGroupName: !Ref DatabaseSubnetGroup
                              
                              

Plus one public-read S3 bucket:

                              
                              # CloudFormation will generate one unique bucket name for us - s3.yaml 
                              # Nothing else to do!
                              Bucket:
                                 Type: AWS::S3::Bucket
                                 Properties:
                                    AccessControl: PublicRead
                              
                              

The web.yaml stack is composed of one ECS cluster and a Launch Configuration for our instances. The LC defines the bootstrap code to execute on each new instance at launch, this is called the User Data. We use here a third-party Docker credential helper that authenticates the Docker client to our ECR registry by turning the instance’s IAM role into security tokens.

                              
                              
                              # This template defines our ECS cluster and its desired size. ecs.yaml
                              # The Launch Configuration defines how each new instance in our cluster should be bootstrapped
                              # through its User Data
                              # The Metadata object gets EC2 instances to register in the ECS cluster
                              ECSCluster:
                                 Type: AWS::ECS::Cluster
                                 Properties:
                                    ClusterName: !Ref EnvironmentName

                              ECSAutoScalingGroup:
                                 Type: AWS::AutoScaling::AutoScalingGroup
                                 Properties:
                                    VPCZoneIdentifier: !Ref PrivateSubnets
                                    LaunchConfigurationName: !Ref ECSLaunchConfiguration
                                    MinSize: !Ref ClusterSize
                                    MaxSize: !Ref ClusterSize
                                    DesiredCapacity: !Ref ClusterSize
                                    Tags:
                                          - Key: Name
                                          Value: !Sub ${EnvironmentName} ECS host
                                          PropagateAtLaunch: true
                                 CreationPolicy:
                                    ResourceSignal:
                                          Timeout: PT15M
                                 UpdatePolicy:
                                    AutoScalingReplacingUpdate:
                                          WillReplace: true
                                    AutoScalingRollingUpdate:
                                          MinInstancesInService: 1
                                          MaxBatchSize: 1
                                          PauseTime: PT15M
                                          SuspendProcesses:
                                          - HealthCheck
                                          - ReplaceUnhealthy
                                          - AZRebalance
                                          - AlarmNotification
                                          - ScheduledActions
                                          WaitOnResourceSignals: true

                              ECSLaunchConfiguration:
                                 Type: AWS::AutoScaling::LaunchConfiguration
                                 Properties:
                                    ImageId:  !FindInMap [AWSRegionToAMI, !Ref "AWS::Region", AMI]
                                    InstanceType: !Ref InstanceType
                                    SecurityGroups:
                                          - !Ref ECSSecurityGroup
                                    IamInstanceProfile: !Ref ECSInstanceProfile
                                    KeyName: laravelaws
                                    UserData: 
                                          "Fn::Base64": !Sub |
                                             #!/bin/bash
                                             yum update -y
                                             yum install -y aws-cfn-bootstrap aws-cli go
                                             echo '{ "credsStore": "ecr-login" }' > ~/.docker/config.json
                                             go get -u github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
                                             cd /home/ec2-user/go/src/github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
                                             go build
                                             export PATH=$PATH:/home/ec2-user/go/bin
                                             /opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSLaunchConfiguration
                                             /opt/aws/bin/cfn-signal -e $? --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSAutoScalingGroup
                                 Metadata:
                                    AWS::CloudFormation::Init:
                                          config:
                                             commands:
                                                01_add_instance_to_cluster:
                                                      command: !Sub echo ECS_CLUSTER=${ECSCluster} >> /etc/ecs/ecs.config
                                             files:
                                                "/etc/cfn/cfn-hup.conf":
                                                      mode: 000400
                                                      owner: root
                                                      group: root
                                                      content: !Sub |
                                                         [main]
                                                         stack=${AWS::StackId}
                                                         region=${AWS::Region}
                                                "/etc/cfn/hooks.d/cfn-auto-reloader.conf":
                                                      content: !Sub |
                                                         [cfn-auto-reloader-hook]
                                                         triggers=post.update
                                                         path=Resources.ECSLaunchConfiguration.Metadata.AWS::CloudFormation::Init
                                                         action=/opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSLaunchConfiguration
                                             services: 
                                                sysvinit:
                                                      cfn-hup: 
                                                         enabled: true
                                                         ensureRunning: true
                                                         files: 
                                                            - /etc/cfn/cfn-hup.conf
                                                            - /etc/cfn/hooks.d/cfn-auto-reloader.conf

                              
                              
                              
                              
                              
                              # This IAM Role is attached to all of the ECS hosts. It is based on the default role - ecs-role.yaml
                              # published here:
                              # http://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html
                              #
                              # You can add other IAM policy statements here to allow access from your ECS hosts
                              # to other AWS services. Please note that this role will be used by ALL containers
                              # running on the ECS host.

                              ECSRole:
                                 Type: AWS::IAM::Role
                                 Properties: 
                                    Path: /
                                    RoleName: !Sub ${EnvironmentName}-ECSRole-${AWS::Region}
                                    AssumeRolePolicyDocument: |
                                          {
                                             "Statement": [{
                                                "Action": "sts:AssumeRole",
                                                "Effect": "Allow",
                                                "Principal": { 
                                                      "Service": "ec2.amazonaws.com" 
                                                }
                                             }]
                                          }
                                    ManagedPolicyArns:
                                          - "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
                                    Policies: 
                                          - PolicyName: ecs-service
                                          PolicyDocument: |
                                             {
                                                "Statement": [{
                                                      "Effect": "Allow",
                                                      "Action": [
                                                         "ecs:CreateCluster",
                                                         "ecs:DeregisterContainerInstance",
                                                         "ecs:DiscoverPollEndpoint",
                                                         "ecs:Poll",
                                                         "ecs:RegisterContainerInstance",
                                                         "ecs:StartTelemetrySession",
                                                         "ecs:Submit*",
                                                         "logs:CreateLogStream",
                                                         "logs:PutLogEvents",
                                                         "ecr:BatchCheckLayerAvailability",
                                                         "ecr:BatchGetImage",
                                                         "ecr:GetDownloadUrlForLayer",
                                                         "ecr:GetAuthorizationToken"
                                                      ],
                                                      "Resource": "*"
                                                }]
                                             }
                                          - PolicyName: ec2-s3-write-access
                                          PolicyDocument:
                                             Statement:
                                                - Effect: Allow
                                                   Action:
                                                   - s3:PutObject
                                                   - s3:GetBucketAcl
                                                   - s3:PutObjectTagging
                                                   - s3:ListBucket
                                                   - s3:PutObjectAcl
                                                   Resource: !Sub arn:aws:s3:::${S3BucketName}/*
                                          - PolicyName: ec2-cloudwatch-write-access
                                          PolicyDocument:
                                             Statement:
                                                - Effect: Allow
                                                Action:
                                                   - logs:CreateLogStream
                                                   - logs:PutLogEvents
                                                   - logs:CreateLogGroup
                                                Resource: "*"

                              ECSInstanceProfile: 
                                 Type: AWS::IAM::InstanceProfile
                                 Properties:
                                    Path: /
                                    Roles: 
                                          - !Ref ECSRole
                              
                              
                              
                              
                              
                              # One Docker registry that we will use both for the Laravel application - ecr.yaml 
                              # image and our Nginx image.
                              # Note that if you give a name to the repository, CloudFormation can't
                              # update it without a full replacement.
                              ECR:
                                 Type: AWS::ECR::Repository
                                 Properties:
                              #            RepositoryName: !Sub ${AWS::StackName}-nginx
                                    RepositoryPolicyText:
                                          Version: "2012-10-17"
                                          Statement:
                                             -
                                                Sid: AllowPushPull
                                                Effect: Allow
                                                Principal:
                                                AWS:
                                                   - !Sub arn:aws:iam::${AWS::AccountId}:role/${ECSRole}
                                                Action:
                                                - "ecr:GetDownloadUrlForLayer"
                                                - "ecr:BatchGetImage"
                                                - "ecr:BatchCheckLayerAvailability"
                                                - "ecr:PutImage"
                                                - "ecr:InitiateLayerUpload"
                                                - "ecr:UploadLayerPart"
                                                - "ecr:CompleteLayerUpload"
                              
                              
                              
                              
                              
                              # One ALB with two listeners for HTTP and HTTPS - alb.yaml 
                              # The HTTP listener will pointed to a specific Nginx container redirecting traffic to HTTPS
                              # because neither ALB or ELB allow you to handle this through their configuration
                              LoadBalancer:
                                 Type: AWS::ElasticLoadBalancingV2::LoadBalancer
                                 Properties:
                                    Name: !Ref EnvironmentName
                                    Subnets: !Ref PublicSubnets
                                    SecurityGroups:
                                          - !Ref LBSecurityGroup
                                    Tags:
                                          - Key: Name
                                          Value: !Ref EnvironmentName

                              LoadBalancerListenerHTTP:
                                 Type: AWS::ElasticLoadBalancingV2::Listener
                                 Properties:
                                    LoadBalancerArn: !Ref LoadBalancer
                                    Port: 80
                                    Protocol: HTTP
                                    DefaultActions:
                                          - Type: forward
                                          TargetGroupArn: !Ref DefaultTargetGroup

                              LoadBalancerListenerHTTPS:
                                 Type: AWS::ElasticLoadBalancingV2::Listener
                                 Properties:
                                    LoadBalancerArn: !Ref LoadBalancer
                                    Port: 443
                                    Protocol: HTTPS
                                    Certificates:
                                          - CertificateArn: !Ref LBCertificateArn
                                    DefaultActions:
                                          - Type: forward
                                          TargetGroupArn: !Ref DefaultTargetGroup

                              # We define a default target group here, as this is a mandatory Parameters
                              # when creating an Application Load Balancer Listener. This is not used, instead
                              # a target group is created per-service in each service template (../services/*)
                              DefaultTargetGroup:
                                 Type: AWS::ElasticLoadBalancingV2::TargetGroup
                                 Properties:
                                    Name: !Sub ${EnvironmentName}-default
                                    VpcId: !Ref VPC
                                    Port: 80
                                    Protocol: HTTP
                              
                              
                              

In more complex setups, we can have our freshly created load balancer registering itself to Route53 so that your service is always available at the same DNS address. This design pattern is called service discovery and is not possible out of the box in CloudFormation. Instead, we will manually point our domain name to our load-balancer on Route53 in step 7 below.

Our load balancer responding but with no healthy container instances behind it

5. Build and push your Laravel Docker image

In the previous step, we created one ECR registry to store both the Docker image of our Laravel application and the one of our Nginx server. ECRs are standard Docker registries which you authenticate to using tokens, that the AWS CLI can generate for us:

                              
                              
                              # The get-login command outputs the "docker login" command you need to execute with a temporary token - script.sh
                              # You can run both directly using eval
                              # The --no-include-email tells get-login not to return the -e option that does not work for all of Docker versions
                              eval $(aws ecr get-login --no-include-email)
                              
                              
                              

Below are the two Dockerfiles we use to build our Docker images:

                              
                              
                                 FROM php:7.1-fpm

                                 # Update packages and install composer and PHP dependencies. Dockerfile 
                                 RUN apt-get update && \
                                 DEBIAN_FRONTEND=noninteractive apt-get install -y \
                                    postgresql-client \
                                    libpq-dev \
                                    libfreetype6-dev \
                                    libjpeg62-turbo-dev \
                                    libmcrypt-dev \
                                    libpng12-dev \
                                    libbz2-dev \
                                    php-pear \
                                    cron \
                                    && pecl channel-update pecl.php.net \
                                    && pecl install apcu

                                 # PHP Extensions
                                 RUN docker-php-ext-install mcrypt zip bz2 mbstring pdo pdo_pgsql pdo_mysql pcntl \
                                 && docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \
                                 && docker-php-ext-install gd

                                 # Memory Limit
                                 RUN echo "memory_limit=2048M" > $PHP_INI_DIR/conf.d/memory-limit.ini
                                 RUN echo "max_execution_time=900" >> $PHP_INI_DIR/conf.d/memory-limit.ini
                                 RUN echo "extension=apcu.so" > $PHP_INI_DIR/conf.d/apcu.ini
                                 RUN echo "post_max_size=20M" >> $PHP_INI_DIR/conf.d/memory-limit.ini
                                 RUN echo "upload_max_filesize=20M" >> $PHP_INI_DIR/conf.d/memory-limit.ini

                                 # Time Zone
                                 RUN echo "date.timezone=${PHP_TIMEZONE:-UTC}" > $PHP_INI_DIR/conf.d/date_timezone.ini

                                 # Display errors in stderr
                                 RUN echo "display_errors=stderr" > $PHP_INI_DIR/conf.d/display-errors.ini

                                 # Disable PathInfo
                                 RUN echo "cgi.fix_pathinfo=0" > $PHP_INI_DIR/conf.d/path-info.ini

                                 # Disable expose PHP
                                 RUN echo "expose_php=0" > $PHP_INI_DIR/conf.d/path-info.ini

                                 # Install Composer
                                 RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

                                 ADD . /var/www/html
                                 WORKDIR /var/www/html

                                 RUN mkdir storage/logs
                                 RUN touch storage/logs/laravel.log
                                 RUN chmod 777 storage/logs/laravel.log

                                 RUN composer install
                                 RUN php artisan optimize --force
                                 # RUN php artisan route:cache

                                 RUN chmod -R 777 /var/www/html/storage

                                 RUN touch /var/log/cron.log

                                 ADD deploy/cron/artisan-schedule-run /etc/cron.d/artisan-schedule-run
                                 RUN chmod 0644 /etc/cron.d/artisan-schedule-run
                                 RUN chmod +x /etc/cron.d/artisan-schedule-run

                                 # CMD ["php-fpm"]

                                 CMD ["/bin/sh", "-c", "php-fpm -D | tail -f storage/logs/laravel.log"],
                              
                              

                              
We install cron here so we can reuse the same image for our Laravel scheduled tasks and our Laravel workers
                              
                              
                                 ## Dockerfile-nginx
                                 FROM nginx

                                 ADD deploy/nginx/nginx.conf /etc/nginx/
                                 ADD deploy/nginx/default.conf /etc/nginx/conf.d/

                                 ADD public /usr/share/nginx/html

                                 WORKDIR /usr/share/nginx/html
                               
                              
                              
Here we simply add our custom Nginx config and the public assets from the Laravel public directory into the Docker image. Each time you rebuild your front-end assets, you will need to re-build both the Laravel and Nginx images

And the command to build them:

                              
                              
                              # Building our Nginx Docker image and tagging it with the ECR URL - dockerbuild.sh
                              docker build -f Dockerfile-nginx -t YOUR_ECR_REGISTRY_URL_HERE:nginx .
                              docker push YOUR_ECR_REGISTRY_URL_HERE:nginx

                              # Building our Laravel Docker image and tagging it with the ECR URL
                              docker build -t YOUR_ECR_REGISTRY_URL_HERE:laravel .
                              docker push YOUR_ECR_REGISTRY_URL_HERE:laravel
                              
                              
                              

Finally, we launch our web service with ECS.
At the core level, task definitions describe which Docker images should be used to create containers, how containers should be linked together and which environment variables to run them with. At an higher level, an ECS service maintains a specified number of instances of a task definition simultaneously in an ECS cluster. The cluster is the pool of EC2 instances ie the infrastructure on which the tasks are hosted.

                              
                              
                                 ## service.yaml
                                 Service: 
                                 Type: AWS::ECS::Service
                                 DependsOn:
                                    - ListenerRuleHTTPS
                                 Properties: 
                                    Cluster: !Ref Cluster
                                    Role: !Ref ServiceRole
                                    DesiredCount: !Ref DesiredCount
                                    TaskDefinition: !Ref TaskDefinition
                                    LoadBalancers: 
                                          - ContainerName: nginx
                                          ContainerPort: 80
                                          TargetGroupArn: !Ref TargetGroup

                              ServiceRedirect:
                                 Type: AWS::ECS::Service
                                 DependsOn:
                                    - ListenerRuleHTTP
                                 Properties:
                                    Cluster: !Ref Cluster
                                    Role: !Ref ServiceRole
                                    DesiredCount: 1
                                    TaskDefinition: !Ref TaskDefinitionRedirectHTTPtoHTTPS
                                    LoadBalancers:
                                          - ContainerName: nginx-to-https
                                          ContainerPort: 80
                                          TargetGroupArn: !Ref TargetGroupRedirectHTTPSToHTTP

                              TaskDefinitionRedirectHTTPtoHTTPS:
                                 Type: AWS::ECS::TaskDefinition
                                 Properties:
                                    Family: nginx-to-https
                                    ContainerDefinitions:
                                          - Name: nginx-to-https
                                          Essential: true
                                          Image: getlionel/nginx-to-https
                                          Memory: 128
                                          PortMappings:
                                             - ContainerPort: 80

                              TaskDefinition:
                                 Type: AWS::ECS::TaskDefinition
                                 Properties:
                                    Family: laravel-nginx
                                    ContainerDefinitions:
                                          - Name: nginx
                                          Essential: true
                                          Image: !Join [ ".", [ !Ref "AWS::AccountId", "dkr.ecr", !Ref "AWS::Region", !Join [ ":", [ !Join [ "/", [ "amazonaws.com", !Ref ECR ] ], "nginx" ] ] ] ]
                                          Memory: 128
                                          PortMappings:
                                             - ContainerPort: 80
                                          Links:
                                             - app
                                          LogConfiguration:
                                             LogDriver: awslogs
                                             Options:
                                                awslogs-group: !Ref AWS::StackName
                                                awslogs-region: !Ref AWS::Region
                                          - Name: app
                                          Essential: true
                                          Image: !Join [ ".", [ !Ref "AWS::AccountId", "dkr.ecr", !Ref "AWS::Region", !Join [ ":", [ !Join [ "/", [ "amazonaws.com", !Ref ECR ] ], "laravel" ] ] ] ]
                                          Memory: 256
                                          LogConfiguration:
                                             LogDriver: awslogs
                                             Options:
                                                awslogs-group: !Ref AWS::StackName
                                                awslogs-region: !Ref AWS::Region
                                          Environment:
                                             - Name: APP_NAME
                                                Value: Laravel
                                             - Name: APP_ENV
                                                Value: production
                                             - Name: APP_DEBUG
                                                Value: false
                                             - Name: APP_LOG_LEVEL
                                                Value: error
                                             - Name: APP_KEY
                                                Value: base64:h2ASblVGbCXbC1buJ8KToZkKIEY69GSiutkAeGo77B0=
                                             - Name: APP_URL
                                                Value: !Ref APPURL
                                             - Name: DB_CONNECTION
                                                Value: !Ref DBCONNECTION
                                             - Name: DB_HOST
                                                Value: !Ref DBHOST
                                             - Name: DB_PORT
                                                Value: !Ref DBPORT
                                             - Name: DB_DATABASE
                                                Value: !Ref DBDATABASE
                                             - Name: DB_USERNAME
                                                Value: !Ref DBUSERNAME
                                             - Name: DB_PASSWORD
                                                Value: !Ref DBPASSWORD
                                             - Name: CACHE_DRIVER
                                                Value: file
                                             - Name: SESSION_DRIVER
                                                Value: database
                                             - Name: MAIL_DRIVER
                                                Value: !Ref MAILDRIVER
                                             - Name: MAIL_HOST
                                                Value: !Ref MAILHOST
                                             - Name: MAIL_PORT
                                                Value: !Ref MAILPORT
                                             - Name: MAIL_USERNAME
                                                Value: !Ref MAILUSERNAME
                                             - Name: MAIL_PASSWORD
                                                Value: !Ref MAILPASSWORD
                                             - Name: MAIL_FROM_ADDRESS
                                                Value: !Ref MAILFROMADDRESS
                                             - Name: MAIL_FROM_NAME
                                                Value: !Ref MAILFROMNAME
                              #                    - Name: ELASTICSEARCH_HOST
                              #                      Value: !Ref ELASTICSEARCHHOST
                              #                    - Name: ELASTICSEARCH_PORT
                              #                      Value: !Ref ELASTICSEARCHPORT
                                             - Name: FILESYSTEM_DRIVER
                                                Value: !Ref FILESYSTEMDRIVER
                                             - Name: AWS_REGION
                                                Value: !Sub ${AWS::Region}
                                             - Name: AWS_BUCKET
                                                Value: !Ref AWSBUCKET

                              CloudWatchLogsGroup:
                                 Type: AWS::Logs::LogGroup
                                 Properties: 
                                    LogGroupName: !Ref AWS::StackName
                                    RetentionInDays: 365  

                              TargetGroup:
                                 Type: AWS::ElasticLoadBalancingV2::TargetGroup
                                 Properties:
                                    VpcId: !Ref VPC
                                    Port: 80
                                    Protocol: HTTP
                                    Matcher: 
                                          HttpCode: 200-301
                                    HealthCheckIntervalSeconds: 10
                                    HealthCheckPath: /
                                    HealthCheckProtocol: HTTP
                                    HealthCheckTimeoutSeconds: 5
                                    HealthyThresholdCount: 2

                              TargetGroupRedirectHTTPSToHTTP:
                                 Type: AWS::ElasticLoadBalancingV2::TargetGroup
                                 Properties:
                                    VpcId: !Ref VPC
                                    Port: 80
                                    Protocol: HTTP
                                    Matcher:
                                          HttpCode: 200-301
                                    HealthCheckIntervalSeconds: 10
                                    HealthCheckPath: /
                                    HealthCheckProtocol: HTTP
                                    HealthCheckTimeoutSeconds: 5
                                    HealthyThresholdCount: 2

                              ListenerRuleHTTP:
                                 Type: AWS::ElasticLoadBalancingV2::ListenerRule
                                 Properties:
                                    ListenerArn: !Ref ListenerHTTP
                                    Priority: 1
                                    Conditions:
                                          - Field: path-pattern
                                          Values:
                                             - !Ref Path
                                    Actions:
                                          - TargetGroupArn: !Ref TargetGroupRedirectHTTPSToHTTP
                                          Type: forward

                              ListenerRuleHTTPS:
                                 Type: AWS::ElasticLoadBalancingV2::ListenerRule
                                 Properties:
                                    ListenerArn: !Ref ListenerHTTPS
                                    Priority: 1
                                    Conditions:
                                          - Field: path-pattern
                                          Values:
                                             - !Ref Path
                                    Actions:
                                          - TargetGroupArn: !Ref TargetGroup
                                          Type: forward

                              # This IAM Role grants the service access to register/unregister with the 
                              # Application Load Balancer (ALB). It is based on the default documented here:
                              # http://docs.aws.amazon.com/AmazonECS/latest/developerguide/service_IAM_role.html
                              ServiceRole: 
                                 Type: AWS::IAM::Role
                                 Properties: 
                                    RoleName: !Sub ecs-service-${AWS::StackName}
                                    Path: /
                                    AssumeRolePolicyDocument: |
                                          {
                                             "Statement": [{
                                                "Effect": "Allow",
                                                "Principal": { "Service": [ "ecs.amazonaws.com" ]},
                                                "Action": [ "sts:AssumeRole" ]
                                             }]
                                          }
                                    Policies: 
                                          - PolicyName: !Sub ecs-service-${AWS::StackName}
                                          PolicyDocument: 
                                             {
                                                "Version": "2012-10-17",
                                                "Statement": [{
                                                         "Effect": "Allow",
                                                         "Action": [
                                                            "ec2:AuthorizeSecurityGroupIngress",
                                                            "ec2:Describe*",
                                                            "elasticloadbalancing:DeregisterInstancesFromLoadBalancer",
                                                            "elasticloadbalancing:Describe*",
                                                            "elasticloadbalancing:RegisterInstancesWithLoadBalancer",
                                                            "elasticloadbalancing:DeregisterTargets",
                                                            "elasticloadbalancing:DescribeTargetGroups",
                                                            "elasticloadbalancing:DescribeTargetHealth",
                                                            "elasticloadbalancing:RegisterTargets"
                                                         ],
                                                         "Resource": "*"
                                                }]
                                             }

                              
                              
                              

It will take a few seconds for our instances to be considered healthy by ELB so it starts directing traffic to them, and that what we see then is:

At least this is a Laravel page, though displaying the default HTTP 500 error message. By checking Laravel logs which are streamed to CloudWatch, we see that we’re missing the session table in the DB. So how can we now connect to one of our instances in the private subnets, across the internet, to run our database migrations?

6. Launch a bastion & run database migrations

A bastion (also called jump box) is a temporary EC2 instance that we will place in a public subnet of our VPC. It will enable us to SSH into it from outside the VPC and from there still being able to access our instances (including database instances) in private subnets.
When creating the bastion, make sure to associate to it the SG allowing access to the database.

                              
                              
                                 ## bastion.sh
                                 aws ec2 run-instances 
                                 --image-id ami-c1a6bda2 
                                 --key-name laravelaws            # the SSH key pair we created earlier
                                 --security-group-ids sg-xxxxxxxx # our previous SG allowing access to the DB
                                 --subnet-id subnet-xxxxxxxx      # one of our public subnets
                                 --count 1 
                                 --instance-type t2.micro         # the smallest instance type allowed
                                 --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=bastion}]'
                              
                              
                              
Launch one bastion, to be deleted once we’re done.
                              
                              
                              # Add your key to your SSH agent - bastion.sh 
                              ssh-add -K laravelaws.pem

                              # Verify that your private key is successfully loaded in your local SSH agent
                              ssh-add –L

                              # Use the -A option to enable forwarding of the authentication agent connection
                              ssh –A ec2-user@

                              # Once you are connected to the bastion, you can SSH into a private subnet instance
                              # without copying any SSH key on the bastion
                              ssh ec2-user@
                              
                              
                              
You’re now connected to an instance inside your VPC private subnets without copying keys around
                              
                              
                                 # Use the Docker exec command to execute the Artisan commands inside the application container-migrations.sh
                                 docker exec -it CONTAINER_ID php artisan session:table
                                 docker exec -it CONTAINER_ID php artisan migrate --force
                              
                              
                              

The bastion can also be a host for a SSH tunnel between our machine and our public subnet so we can connect a local mysql/pgsql client to our remote database. Below is an example for PostgreSQL:

                              
                              
                                 # create a SSH tunnel to RDS through your bastion: ssh-tunnel.sh
                                 ssh -L 54320:your_rds_database_endpoint_here.your_region_here.rds.amazonaws.com:5432 
                                    ec2-user@ 
                                    -i ./laravelaws.pem

                                 # Your remote database is now accessible from port 54320 on your local machine
                                 # I strongly recommend to create first thing a read-only user in your database
                                 psql -h localhost -p 54320 -U postgres -W db_name_here
                                 > CREATE ROLE lionel LOGIN PASSWORD 'a_unique_password_here';
                                 > GRANT CONNECT ON DATABASE crvs TO lionel;
                                 > GRANT USAGE ON SCHEMA public TO lionel;
                                 > GRANT SELECT ON ALL TABLES IN SCHEMA public TO lionel;
                                 > GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO lionel

                                 # You can then use pg_dump, pg_restore, or pgsql command line tools to create/restore a DB dump
                                 pg_dump -h localhost -U lionel -W -p 54320 db_name_here > dump_db_name_here_$(date +"%m_%d_%Y").sql

                                 # Import it into a local database using:
                                 psql -U lionel -w db_name_here -f dump_db_name_here_11_23_2017.sql
                              
                              
                              

Back to our database migrations that we just ran. Here’s how it looks now when connecting to the load balancer:

Laravel served through our load balancer URL

Yay! Our application is now served through our load balancer and our EC2 and database instances are running from the safety of a private subnet. The next step is to point our domain name to our load balancer.

7. Migrate DNS service to AWS Route53
If you have bought your domain name outside of AWS, you usually don’t need to migrate either the registration or the DNS service to your AWS account.
There is an edge case though if you want your root domain (also known as APEX) to point to your load balancer. This needs a CNAME record which is not allowed for APEXs but AWS Route53 offers a special type of ALIAS records that lets you do just that.

First we will migrate your DNS service to AWS:

                              
                              
                                 # create a hosted zone for AWS to select NS servers for your domain - create-hosted-zone.sh 
                                 aws route53 create-hosted-zone
                                    --name laravelaws.com
                                    --caller-reference random_string_here
                                    
                                 # wait for the hosted zone to be created

                                 # retrieve NS records
                                 aws route53 get-hosted-zone
                                       --id /hostedzone/YOUR_HOSTED_ZONE_ID

                                 # the NS addresses in the response are the one to upload to your current domain name registrar
                                 {
                                    "HostedZone": {
                                       "Id": "/hostedzone/YOUR_HOSTED_ZONE_ID",
                                       "Name": "laravelaws.com.",
                                       "CallerReference": "RISWorkflow-RD:824653d6-3f9d-415a-a2e8-8d6fa63fb4c8",
                                       "Config": {
                                             "Comment": "HostedZone created by Route53 Registrar",
                                             "PrivateZone": false
                                       },
                                       "ResourceRecordSetCount": 6
                                    },
                                    "DelegationSet": {
                                       "NameServers": [
                                             "ns-1308.awsdns-03.org",
                                             "ns-265.awsdns-32.com",
                                             "ns-583.awsdns-08.net",
                                             "ns-1562.awsdns-03.co.uk"
                                       ]
                                    }
                                 }

                                 # retrieve the TTL for your NS records.
                                 # This is the maximum time it will take for all clients to point to Route53
                                 # after you uploaded them to your current domain name registrar
                                 dig laravelaws.com
                              
                              
                              

Once the DNS service is assumed by Route53, we can create an ALIAS record to our ELB URL.

                              
                              
                                 # Add an ALIAS record to ELB URL - change-resource-record-sets.sh
                                 aws route53 change-resource-record-sets 
                                    --hosted-zone-id /hostedzone/YOUR_HOSTED_ZONE_ID
                                    --change-batch '{
                                    "Changes":[
                                       {
                                          "Action":"CREATE",
                                          "ResourceRecordSet":{
                                             "Name":"laravelaws.com.",
                                             "Type":"A",
                                             "AliasTarget":{
                                                "DNSName":"laravelaws2-1297867430.ap-southeast-2.elb.amazonaws.com",
                                                "EvaluateTargetHealth":true,
                                                "HostedZoneId":"YOUR_HOSTED_ZONE_ID"
                                             }
                                          }
                                       }
                                    ]
                                 }'

                                 # Track the propagation of the record
                                 aws route53 get-change --id /change/YOUR_CHANGE_ID

                                 # Test your record even before it is propagated
                                 aws route53 test-dns-answer
                                       --hosted-zone-id /hostedzone/YOUR_HOSTED_ZONE_ID
                                       --record-name laravelaws.com 
                                       --record-type A
                              
                              
                              

All done!

Domain name pointing to the load balancer, SSL certificate working

You are potentially done at this point. You can also improve your stack and deployment systems by following the steps below.

8. Speed up your application by using CloudFront

Add a CloudFront distribution in your CloudFormation template and update your stack:

                              
                              
                                 ## cloudformation.yaml 
                                 CloudFrontDistribution:
                                 Type: AWS::CloudFront::Distribution
                                 Properties:
                                    DistributionConfig:
                                          Origins:
                                             - DomainName: !Ref S3BucketDNSName
                                                Id: myS3Origin
                                                S3OriginConfig:
                                                   OriginAccessIdentity: !Ref CloudFrontOAI
                                          Enabled: 'true'
                                          Aliases:
                                             - !Ref CDNAlias
                                          DefaultCacheBehavior:
                                             Compress: 'true'
                                             AllowedMethods:
                                                - GET
                                                - HEAD
                                                - OPTIONS
                                             TargetOriginId: myS3Origin
                                             ForwardedValues:
                                                QueryString: 'false'
                                                Cookies:
                                                   Forward: none
                                             ViewerProtocolPolicy: redirect-to-https
                                          ViewerCertificate:
                                             AcmCertificateArn: !Ref CertificateArn
                              
                              
                              

You will need to create beforehand a CloudFront Origin Access Identity, which is a special CloudFront user who will be able query objects in your S3 bucket:

                              
                              
                                 ## cloudfront-oai.sh 
                                 aws cloudfront create-cloud-front-origin-access-identity
                                 --cloud-front-origin-access-identity-config CallerReference=random_string_here,Comment=

                              
                              
                              

Create an ALIAS record to point files.yourdomain.com to your CF distribution:

                              
                              
                                 # Add an ALIAS record to ELB URL - change-resource-record-sets-bis.sh
                                 aws route53 change-resource-record-sets 
                                    --hosted-zone-id /hostedzone/YOUR_HOSTED_ZONE_ID
                                    --change-batch '{
                                    "Changes":[
                                       {
                                          "Action":"CREATE",
                                          "ResourceRecordSet":{
                                             "Name":"files.laravelaws.com.",
                                             "Type":"A",
                                             "AliasTarget":{
                                                "DNSName":"d165d2lrm1x3fz.cloudfront.net",
                                                "EvaluateTargetHealth":true,
                                                "HostedZoneId":"YOUR_HOSTED_ZONE_ID"
                                             }
                                          }
                                       }
                                    ]
                                 }'
                              
                              
                              

Add a sub_filter Nginx directive to rewrite all URLs to your S3 buckets as links to your CF distribution instead.

                              
                              
                                 ## nginx.conf 
                                 location ~ \.php$ {
                                    root /var/www/html/public;
                                    fastcgi_cache cache_key;
                                    fastcgi_cache_valid 200 204 1m;
                                    fastcgi_ignore_headers Cache-Control;
                                    fastcgi_no_cache $http_authorization $cookie_laravel_session;
                                    fastcgi_cache_lock on;
                                    fastcgi_cache_lock_timeout 10s;

                                    add_header X-Proxy-Cache $upstream_cache_status;

                                    sub_filter_types *;
                                    sub_filter_once off;
                                    sub_filter 'laravelaws-bucket-jjua0wgxhi7i.s3-ap-southeast-2.amazonaws.com' 'files.laravelaws.com';

                                    fastcgi_pass   app:9000;
                                    fastcgi_index  index.php;
                                    fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
                                    fastcgi_read_timeout 900s;
                                    include        fastcgi_params;
                                 }
                              
                              
                              

9. (Optional) Publish your Laravel workers and crons
Well done! Our Laravel application is now highly available in the cloud. This step will show how we can reuse our exact same Laravel Docker image to deploy our scheduled tasks and workers. They will run in their own containers and be managed by another ECS service so we can scale them independently to the php-fpm containers. We also make sure we have only a single instance of cron running, even if we have multiple front-end containers.

For the worker jobs, we create an SQS queue using CloudFormation, for the front-end to dispatch jobs to our workers in the background:

                              
                              
                                 # That's all it takes to create a queue in CloudFormation - sqs.yaml
                                 # CloudFormation will assign a unique name to it, that we
                                 # will pass to our Laravel containers
                                 Queue:
                                    Type: AWS::SQS::Queue

                                 # Then in the web.yaml stack, we update our ECSRole to grant
                                 # our ECS instances access to this one queue we just created
                                       - PolicyName: sqs-read-write-access
                                       PolicyDocument:
                                          Statement:
                                             - Effect: Allow
                                             Action:
                                                - sqs:*
                                             Resource: !GetAtt Queue.Arn
                              
                              

Finally we create two more tasks definitions in CloudFormation by starting from the same Laravel Docker image, same environment variables, but just overriding the Docker CMD (i.e. the command executed by Docker when the container starts):

                              
                              
                                 # The worker containers simply execute the Laravel artisan queue:work - task-definitions.yaml 
                                 # command instead of php-fpm
                                 TaskDefinitionWorker:
                                    Type: AWS::ECS::TaskDefinition
                                    Properties:
                                       Family: laravel-workers
                                       ContainerDefinitions:
                                             - Name: app
                                             Essential: true
                                             Image: !Join [ ".", [ !Ref "AWS::AccountId", "dkr.ecr", !Ref "AWS::Region", !Join [ ":", [ !Join [ "/", [ "amazonaws.com", !Ref ECR ] ], "laravel" ] ] ] ]
                                             Command:
                                                - "/bin/sh"
                                                - "-c"
                                                - "php artisan queue:work"
                                             Memory: 128
                                             LogConfiguration:
                                                LogDriver: awslogs
                                                Options:
                                                   awslogs-group: !Ref AWS::StackName
                                                   awslogs-region: !Ref AWS::Region
                                             Environment:
                                                - Name: APP_NAME
                                                   Value: Laravel
                                                   ......
                                                   
                                 # The cron container command is a bit more intricate
                                 # since we need to load the container's environment
                                 # variables in the same console session context than cron
                                 # for Laravel to use them
                                 TaskDefinitionCron:
                                    Type: AWS::ECS::TaskDefinition
                                    Properties:
                                       Family: laravel-cron
                                       ContainerDefinitions:
                                             - Name: app
                                             Essential: true
                                             Image: !Join [ ".", [ !Ref "AWS::AccountId", "dkr.ecr", !Ref "AWS::Region", !Join [ ":", [ !Join [ "/", [ "amazonaws.com", !Ref ECR ] ], "laravel" ] ] ] ]
                                             EntryPoint:
                                                - /bin/bash
                                                - -c
                                             Command:
                                                - env /bin/bash -o posix -c 'export -p' > /etc/cron.d/project_env.sh && chmod +x /etc/cron.d/project_env.sh && crontab /etc/cron.d/artisan-schedule-run && cron && tail -f /var/log/cron.log
                                             Memory: 128
                                             LogConfiguration:
                                                LogDriver: awslogs
                                                Options:
                                                   awslogs-group: !Ref AWS::StackName
                                                   awslogs-region: !Ref AWS::Region
                                             Environment:
                                                - Name: APP_NAME
                                                   Value: Laravel
                              
                              

The crontab file we use to call the artisan scheduler loads the container’s environment variables in the cron console session. If you don’t, Laravel won’t see your container’s env vars when called from the cron.

                              
                              
                                 # artisan-schedule-run
                                 * * * * * root . /etc/cron.d/project_env.sh ; /usr/local/bin/php /var/www/html/artisan schedule:run &> /var/log/cron.log
                                 # An empty line is required at the end of this file for a valid cron file.

                              
                              

That’s it! We now have in our cluster a mix of Laravel front-end containers (php-fpm with Nginx as a reverse proxy), Laravel workers and one cron.

10. (Optional) Add an ElasticSearch domain
Most web applications would need a search engine like ElasticSearch. This is how you can create a managed ES cluster with CloudFormation.

                              
                              
                                 ## elasticsearch.yml
                                 Elasticsearch:
                                    Type: AWS::Elasticsearch::Domain
                                    Properties:
                                       DomainName: !Sub ${AWS::StackName}-es
                                       ElasticsearchVersion: 5.5
                                       ElasticsearchClusterConfig:
                                             InstanceType: t2.small.elasticsearch
                                             ZoneAwarenessEnabled: false
                                             InstanceCount: 1
                                       EBSOptions:
                                             EBSEnabled: true
                                             VolumeSize: 10
                                       AccessPolicies:
                                          Version: 2012-10-17
                                          Statement:
                                             - Effect: Allow
                                             Principal:
                                                AWS: "*"
                                             Action:
                                                - es:ESHttpDelete
                                                - es:ESHttpGet
                                                - es:ESHttpHead
                                                - es:ESHttpPost
                                                - es:ESHttpPut
                                             Resource: !Sub arn:aws:es:${AWS::Region}:${AWS::AccountId}:domain/${AWS::StackName}-es/*
                                             Condition:
                                                IpAddress:
                                                   aws:SourceIp:
                                                   - !GetAtt VPC.Outputs.NatGateway1EIP
                                                   - !GetAtt VPC.Outputs.NatGateway2EIP
                              
                              
                              
Note that we only allow ingress traffic from both our NAT gateway IPs, ie only instances from our private subnets

11. (Optional) High availability for the storage tier
As we discussed previously, we only have one database instance and no read replica in a separate AZ. You can add a replica in CloudFormation with the below template:

                              
                              
                                 ## storage-high-availability.yaml 
                                 DatabaseReplicaInstance:
                                    Type: AWS::RDS::DBInstance
                                    DependsOn: DatabasePrimaryInstance
                                    Properties:
                                       Engine: aurora
                                       DBClusterIdentifier: !Ref DatabaseCluster
                                       DBInstanceClass: !Ref DatabaseInstanceType
                                       DBSubnetGroupName: !Ref DatabaseSubnetGroup
                              
                              
                              
Use the DependsOn directive to avoid your replica to be instantiated before and be promoted as the primary instance by Aurora

Note that Aurora only supports instances starting at db.r4.large size for PostgreSQL whereas Aurora MySQL does start at db.t2.small instances.

12. CloudWatch alarms

Below we set up CPU, memory and replication alarms for our database:

                              
                              
                                 ## cloudwatch-alarms.yaml 
                                 StackAlarmTopic:
                                    Type: AWS::SNS::Topic
                                    Properties:
                                       DisplayName: Stack Alarm Topic
                                       
                                 DatabasePrimaryCPUAlarm:
                                    Type: AWS::CloudWatch::Alarm
                                    Properties:
                                       AlarmDescription: Primary database CPU utilization is over 80%.
                                       Namespace: AWS/RDS
                                       MetricName: CPUUtilization
                                       Unit: Percent
                                       Statistic: Average
                                       Period: 300
                                       EvaluationPeriods: 2
                                       Threshold: 80
                                       ComparisonOperator: GreaterThanOrEqualToThreshold
                                       Dimensions:
                                             - Name: DBInstanceIdentifier
                                             Value:
                                                   Ref: DatabasePrimaryInstance
                                       AlarmActions:
                                             - Ref: StackAlarmTopic
                                       InsufficientDataActions:
                                             - Ref: StackAlarmTopic
                                             
                                 DatabaseReplicaCPUAlarm:
                                    Type: AWS::CloudWatch::Alarm
                                    Properties:
                                       AlarmDescription: Replica database CPU utilization is over 80%.
                                       Namespace: AWS/RDS
                                       MetricName: CPUUtilization
                                       Unit: Percent
                                       Statistic: Average
                                       Period: 300
                                       EvaluationPeriods: 2
                                       Threshold: 80
                                       ComparisonOperator: GreaterThanOrEqualToThreshold
                                       Dimensions:
                                             - Name: DBInstanceIdentifier
                                             Value:
                                                   Ref: DatabaseReplicaInstance
                                       AlarmActions:
                                             - Ref: StackAlarmTopic
                                       InsufficientDataActions:
                                             - Ref: StackAlarmTopic
                                             
                                 DatabasePrimaryMemoryAlarm:
                                    Type: AWS::CloudWatch::Alarm
                                    Properties:
                                       AlarmDescription: Primary database freeable memory is under 700MB.
                                       Namespace: AWS/RDS
                                       MetricName: FreeableMemory
                                       Unit: Bytes
                                       Statistic: Average
                                       Period: 300
                                       EvaluationPeriods: 2
                                       Threshold: 700000000
                                       ComparisonOperator: LessThanOrEqualToThreshold
                                       Dimensions:
                                             - Name: DBInstanceIdentifier
                                             Value:
                                                   Ref: DatabasePrimaryInstance
                                       AlarmActions:
                                             - Ref: StackAlarmTopic
                                       InsufficientDataActions:
                                             - Ref: StackAlarmTopic
                                             
                                 DatabasePrimaryReplicationAlarm:
                                    Type: AWS::CloudWatch::Alarm
                                    Properties:
                                       AlarmDescription: Database replication latency is over 200ms.
                                       Namespace: AWS/RDS
                                       MetricName: AuroraReplicaLag
                                       Unit: Milliseconds
                                       Statistic: Average
                                       Period: 300
                                       EvaluationPeriods: 2
                                       Threshold: 200
                                       ComparisonOperator: GreaterThanOrEqualToThreshold
                                       Dimensions:
                                             - Name: DBInstanceIdentifier
                                             Value:
                                                   Ref: DatabaseReplicaInstance
                                       AlarmActions:
                                             - Ref: StackAlarmTopic
                                             
                                 DatabaseReplicaReplicationAlarm:
                                    Type: AWS::CloudWatch::Alarm
                                    Properties:
                                       AlarmDescription: Database replication latency is over 200ms.
                                       Namespace: AWS/RDS
                                       MetricName: AuroraReplicaLag
                                       Unit: Milliseconds
                                       Statistic: Average
                                       Period: 300
                                       EvaluationPeriods: 2
                                       Threshold: 200
                                       ComparisonOperator: GreaterThanOrEqualToThreshold
                                       Dimensions:
                                             - Name: DBInstanceIdentifier
                                             Value:
                                                   Ref: DatabaseReplicaInstance
                                       AlarmActions:
                                             - Ref: StackAlarmTopic
                              
                              
                              

And for the ECS instances:

                              
                              
                                 ## alarms.yaml 
                                 AlarmTopic:
                                    Type: AWS::SNS::Topic
                                    Properties:
                                       Subscription:
                                             - Endpoint: [email protected]
                                             Protocol: email

                                 CPUAlarmHigh:
                                    Type: AWS::CloudWatch::Alarm
                                    Properties:
                                       EvaluationPeriods: '1'
                                       Statistic: Average
                                       Threshold: '50'
                                       AlarmDescription: Alarm if CPU too high or metric disappears indicating instance is down
                                       Period: '60'
                                 #            AlarmActions:
                                 #                - Ref: ScaleUpPolicy
                                       AlarmActions:
                                             - Ref: AlarmTopic
                                       Namespace: AWS/EC2
                                       Dimensions:
                                             - Name: AutoScalingGroupName
                                             Value: !Ref ECSAutoScalingGroup
                                       ComparisonOperator: GreaterThanThreshold
                                       MetricName: CPUUtilization
                              
                              
                              

13. (Optional) Updating your stack manually — vertical scaling or manual horizontal scaling

To create your CloudFormation stack the first time, use the below command:

                              
                              
                                 # Create your CloudFormation stack from scratch using the create-stack command - cloudformation.sh
                                 aws cloudformation create-stack
                                    --stack-name=laravel 
                                    --template-body=file://master.yaml 
                                    --capabilities CAPABILITY_NAMED_IAM 
                                    --parameters 
                                       ParameterKey=CloudFrontOAI,ParameterValue=origin-access-identity/cloudfront/YOUR_CF_OAI_HERE 
                                       ParameterKey=CertificateArnCF,ParameterValue=arn:aws:acm:us-east-1:your_cloudfront_certificate_arn_here
                                       ParameterKey=CertificateArn,ParameterValue=arn:aws:acm:us-east-1:your_certificate_arn_here 
                                       ParameterKey=BaseUrl,ParameterValue=laravelaws.com 
                                       ParameterKey=DBMasterPwd,ParameterValue=your_master_db_pwd_here
                                       ParameterKey=ECSInstanceType,ParameterValue=t2.micro
                                       ParameterKey=ECSDesiredCount,ParameterValue=1
                              
                              
                              
                              

If you later want to modify the number or size of the instances in your cluster, update the parameters ECSInstanceType and ECSDesiredCount in your command line and call the update-stack command instead. CloudFormation will un-provision your previous instances and launch the new instances without further intervention needed from you.

14. (Optional) Auto scaling

Here we will use a combination of CloudWatch alarms, ScalableTargets and ScalingPolicies to trigger scaling of both our ECS cluster size and the desired number of container instances in our ECS. Scaling will happen both ways, so our infrastructure will typically be as light as possible at night and then scale up for peak times!

Coming soon

15. (Optional) Set up Continuous Deployment with CodePipeline

This is where we’ll automate the building of our images from our GitHub repository. Once images are built and tested (using built-in Laravel unit and integration tests), they will be deployed in production without further clicking.
Containers will be replaced in sequence using a deployment pattern called Blue-Green deployment, so we get absolutely no downtime.

I’ve written about how to setup CodePipeline for Laravel here!

16. (Optional) Setup SES and a mail server

If you’ve bought your domain name from Route53 instead of another domain name registrar, you don’t have a mail service ie you can’t receive emails on your new domain name. AWS has no other solution for you than letting you host a mail server on an EC2 instance and get your MX records to point at it, or to set up a custom Lambda function to redirect your incoming emails to GMail for example.

Coming soon

17. Cost containment

If you are running this architecture at scale, there are a couple ways to contain your AWS bill. First you could point your application to the Aurora Read Replicas for read-only queries, to offload your primary instance and avoid vertically scaling too much.

Then you could commit to EC2 Reserved instances and pay for some of your instances cost upfront. Doing so can reduce your EC2 bill by as much as 75%. If your traffic fluctuates a lot throughout the day, you could have reserved instances running continuously and scale up with On-Demand instances during peak times.

Finally, a more sophisticated approach would be to scale using EC2 Spot instances but it is only recommended for your background workload as Spot instances can be terminated by AWS at a short notice.

18. (Optional) Deleting your stack and free resources

Once you’re done experimenting, you can wind down all the resources you created through CloudFormation with one single command. Now you can be sure you did not forget an instance or NAT gateway somewhere silently adding to your AWS bill.

                              
                              
                              # cf-delete-stack.sh 
                              aws cloudformation delete-stack --stack-name=laravelaws

                              
                              
                              

I hope that was helpful and got you to adopt infrastructure-as-code. If it has been helpful, please comment, clap or share!

Lionel is Chief Technology Officer of London-based startup Wi5 and author of the Future-Proof Engineering Culture course . You can reach out to him on https://getlionel.com

Fonte: https://github.com/lucenarenato/laravelaws


3

Share

Donate to Site


About Author

Renato

Developer

Add a Comment
Comments 0 Comments

No comments yet! Be the first to comment

Blog Search


Categories

Laravel (227) PHP (151) linux (124) Variados (110) Dicas (58) ubuntu (58) developer (48) postgresql (45) database (44) sql (42) Docker (32) front-end (31) mysql (31) devops (26) webdev (24) programming (23) aws (19) tecnologia (19) eloquent (19) dba (18) OUTROS (17) backend (16) laravelphp (16) debian (12) dev (12) reactjs (10) 100DaysOfCode (10) git (10) react (10) nginx (9) inteligencia-artificial (9) PHP Swoole (9) node (9) javascript (9) linux-tools (8) Architecture (8) vue (7) github (7) ciencia (7) nodejs (6) api (6) vscode (6) webservice (6) jwt (6) vim (6) windows (6) arquitetura (6) authentication (5) ia (5) reactnative (5) rest (5) DevSecOps (5) servers (5) apache (5) macox (5) s3 (5) Kubernetes (4) gitlab (4) opensource (4) mariadb (4) jenkins (4) shell (4) mongodb (4) angular (4) autenticacao (4) wsl (4) Swoole (4) lets-encrypt (4) query (4) Raspberry (4) angularjs (4) inteligenciadedados (4) Padrao de design (4) artigo (4) google (4) npm (4) openai (4) js (3) mysqli (3) Black Hat (3) RabbitMQ (3) educacao (3) intel (3) CMS (2) sail (3) script (3) performance (3) json (3) authorization (3) phpswoole (3) ddd (3) blade (3) terminal (3) log (3) mac (3) fedora (3) containers (3) ssh (3) bash (3) hardware (3) tests (3) macos (3) web (2) jobs (3) websocket (3) db (3) politica (3) Curisidades (2) Solid (2) zsh (2) Go (2) BigLinux (2) POO (2) LazyVim (2) gource (2) Python (2) Oauth2 (2) android (2) unix (2) magento (2) iot (2) ffmpeg (2) combustivel (2) webhook (2) microservices (2) bancodedados (2) tailwind (2) homeOffice (2) html (2) openswoole (2) artificialintelligence (2) security (2) auth (2) cron (2) phpunit (2) kube (2) multiple_authen (2) policia (2) neovim (2) golang (2) noticias (2) livros (2) Transcribe (2) ElonMusk (2) redis (2) claude (2) ArchLinux (2) java (2) saude (1) seguranca (2) phpfpm (2) autorizacao (2) monitoring (2) laptop (2) gnome (2) powerbi (2) telefonia (2) nvm (2) imagick (2) maps (2) colors (2) Passport (2) JQuery (2) front (1) wine (1) covid19 (0) services (1) phpjasper (1) models (1) kali-linux (1) geojson (1) yarn (1) picpay (1) Monolith (1) banco (1) PNPM (1) Desenvolvedor (1) Structurizr (1) symfony (1) presenter (1) lider (1) guard (1) tensorflow (1) bootstrap (1) nuance (1) historia (1) dropbox (1) traefik (1) bug (1) akitando (1) llm (1) htm (1) transformers (1) cavalotroia (1) odd (1) m1 (1) Error (1) cinnamon (1) repmgr (1) federal (1) ruby (1) AppSec (1) orm (1) ArquiteturaDeSoftware (1) Passwordless (1) memcached (1) flow (1) compression (1) athena (1) Migration (1) workflow (1) cqrs (1) kitematic (1) geospacial (1) yeshua (1) data (1) sonarqube (1) Axios (1) pipelines (1) Mozilla (1) kvm (1) GitOps (1) sqlite (1) podcast (1) n8n (1) LaravelFilament (1) God (1) DesenvolvimentoProfissional (1) sw (1) bigtech (1) postgres (1) NoCookies (1) LeetCode (1) governancadedados (1) prf (1) nosql (1) Lideranca (1) Hackers (1) Bots (1) pytorch (1) nuxt (1) liquid (1) ec2 (1) transaction (1) c4 (1) rancher (1) algoritimo (1) Observability (1) Elasticsearch (1) translate (1) certbot (1) Oh My Zsh (1) ibm (1) escopos (1) usb (1) ckeditor (1) API_KEY_GOOGLE_MAPS (1) Manjaro (1) vicuna (1) coding (1) rust (1) markdown (1) JasperReports (1) Fibonacci (1) community (1) Samurai (1) payment (1) messaging (1) Jesus (1) flutter (1) militar (1) fullsta (1) smartphones (1) automacao (1) Monitor (1) zend (1) spaceship (1) PKCE (1) l2tp (1) Glacier (1) laraveloctane (1) Deus (1) binaural (1) gpt (1) bolsonaro (1) privacidade (1) linkedin (1) documentation (1) brain (1) adb (1) nvidia (1) host (1) ecommerce (1) c4-models (1) altadisponibilidade (1) octane (1) lucena (1) http (1) TypeScript (1) chatgpt (1) idiomas (1) eventdrive (1) uuid (1) restfull (1) aplicativo (1) optimization (1) mapas (1) Fetch (1) collections (1) RustLang (1) matematica (1) Filament (1) compactar (1) paypal (1) microg (1) forcas armadas (1) cor (1) auth (1) modelagemdedados (1) k8s (1) gasolina (1) wsl2 (1) csv (1) soap (1) piada (1) KubeCon (1) zorin-os (1) spring-boot (1) backup (1) playwright (1) Deepin (1) storage (1) benchmark (1) networking (1) Swoole (1) biologia (1) node-red (1) LETSENCRYPT (1) Grunt (1) Diagramas (1) boot (1) haru (1) dracula (1) TrabalhoEmEquipe (1) Brasil (1) queue (1) agi (1) llama (1) hotfix (1) economia (1) transcription (1) cache (1) Amazon (1) October (1) lumen (1) Hyperf (1) replication (1) faceapp (1) vala (1) cloudstack (1) rpi (1) apple (1) oracle (1) iode (1) ffaa (1) vpn (1) MeioAmbiente (1) firefox (1) composer (1) scheduling (1) Asahi (1) pendrive (1) microservice (1) front (1) OOD (0) controllers (0)

New Articles



Get Latest Updates by Email