Build and Deploy a Symfony Application on AWS using CDK, ECS and RDS

Julien Bras
Wiiisdom Labs
Published in
12 min readNov 29, 2021

Introduction

Why invest in AWS CDK today? Using a simple example with a basic web application built with the Symfony framework, this article will show you how to industrialize any application with Infrastructure as Code (IaC) methodology on AWS. It will increase both the reliability of your application by using specialized components, and the productivity of your team by providing a clear Continuous Delivery process. First you will get a big picture of the concepts and tools used, then you will be presented with a step-by-step guide to build both the web application and the CDK project.

What is Symfony ?

Symfony is one of the most known frameworks in the PHP ecosystem. Its first release was in 2005 and it is still a living project now today, with Symfony 5. It’s an equivalent of the Laravel framework but built by frenchies! (Symfony is baked by SensioLabs, a french company)

At Wiiisdom, the product license management application has been running on Symfony since 2014. Back in the day, our major hosting solution was only providing PHP/MySQL environments, that’s why we chose Symfony. It’s a great solution because it enforces a structured development strategy (MVC, specific path for controllers, models and views, etc.).

For a few years the application was running on a single AWS EC2 instance, as a standard application deployment. Each release of our solution was difficult as no Continuous Delivery process was implemented. It required manual action on the EC2 instance in order to upgrade the code and update the database when necessary. We spotted an opportunity to improve the delivery process and optimize the reliability of the solution by using more specialized AWS services.

Why so many AWS services ?

In order to improve the reliability of our solution it was imperative to get rid of the single EC2 instance architecture. Even if we had backups, the actual instance was running both the PHP engine and the MySQL database. So if an event occurred and stopped the instance, the whole service would be unavailable. As EC2 instances are running in a particular AWS Availability Zone, it’s always possible for the AZ to go down (very low probability) or for an issue to occur on the physical server that is running our EC2 instance (higher probability).

The best option for a PHP workload is to migrate to a containerized solution, like AWS ECS (Elastic Container Service) or AWS EKS (Elastic Kubernetes Service). We briefly experienced a PHP Serverless framework ( bref.sh) but the test showed that a lot of code would have to be rewritten in that case. Containers, on the other hand, allow to implement a horizontal load balancing (start more containers if needed), and are easier to manage with a proper Continuous Delivery process.

Regarding the database, it’s easy to host a MySQL database using the AWS RDS (Relational Database Service) offer. The service is handling multiple important features:

  • backup / automatic snapshot, to periodically backup your data
  • multi-AZ option, to have a AZ resilient database

It is also necessary to handle external access via an Application Load Balancer (ALB) to redirect to the ECS tasks. This element will also take care of the HTTPS connectivity.

( The architecture schema has been built with CDK-DIA!)

A quick note regarding the schema: it shows 2 identical stacks ( license-360suite-prod and license-360suite-dev) as both stacks are defined in the CDK application, but it uses the same stack definition.

Why invest in AWS CDK today ?

It’s possible to build such infrastructure with the AWS Console without relying on any toolkit like the CDK (Cloud Development Kit). There is a nice example here: Deploying a Symfony 4/5 Application on AWS Fargate (part 1). But the deployment process would be very painful with a lot of manual steps and we want to have the simplest experience to release the project in the test or production environment. Additionally, using a IaC ( Infrastructure As Code) toolkit will help to replicate the system in test or development with very little modifications. This way, your infrastructure will be shipped as “code” with your actual application code: it will be versioned, it can be tested, it allows rollbacks, etc.

The main benefit of CDK versus Terraform is the ability to rely on high-level structures, named constructs, that speed up the creation of your CDK project (take a look at the ApplicationLoadBalancedFargateService construct, it will be used in the next section). It’s also using an existing programming language (like TypeScript) so you don’t have to learn a different language, and you can rely on the code completion features of your IDE.

Part 1 — Build the Symfony application

Let’s keep this section minimal: the goal is to build a running Symfony 5 application that will be later deployed using CDK. You will be shown how to rely on docker in order to test the application locally and mimic the ECS setup. You must have the following tools installed for this part: composer, Docker Desktop, and docker-compose.

Let’s init and access our projectfolder by running mkdir symfony_cdk && cd symfony_cdk, where you will organize both the Symfony base code and the CDK project as follows:

symfony_cdk/
app/ # Symfony project
bin/ # CDK project folder
lib/ # CDK project folder

Relying on Symfony documentation, let’s create a new Symfony application (traditional web application):

composer create-project symfony/website-skeleton app

It will create a full-featured Symfony application (5.3 version at the time the article is written) in the app/ folder. Then you will be able to build your application using any backend database you will choose. Assuming this is done, you need now to set up Docker in order to build a docker-compose configuration.

Create a app/docker/php-fpm/Dockerfile with following content:

FROM php:8-fpmCOPY . /app/RUN apt-get update && \
apt-get install -y --no-install-recommends git unzip libzip-dev && \
docker-php-ext-install mysqli pdo pdo_mysql zip && \
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composerWORKDIR /app CMD composer i -o ; php bin/console doctrine:schema:update --complete --force ; php-fpmEXPOSE 9000

This will build a docker image for your Symfony application using php-fpm.

Then create a app/docker/nginx/Dockerfile file with:

FROM nginx:alpineCOPY . /app
COPY docker/nginx/default.conf /nginx.conf.template
CMD ["/bin/sh" , "-c" , "envsubst '$PHP_HOST' < /nginx.conf.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"]EXPOSE 80

And also a app/docker/nginx/default.conf file with:

server {

listen 80 default_server;
server_name localhost;
root /app/public;
index index.php index.html index.htm;

location / {
try_files $uri /index.php$is_args$args;
}

location ~ ^/index\\.php(/|$) {
fastcgi_pass ${PHP_HOST}:9000;
fastcgi_split_path_info ^(.+\\.php)(/.*)$;
include fastcgi_params;

fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;

fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;

internal;
}

location ~ \\.php$ {
return 404;
}
}

This will configure a nginx container that will be the webserver, sending requests to php when dynamic rendering is needed. It’s a best practice when using containers to separate the concern if possible (web server vs application server).

Finally you can create a docker-compose.yml file at the root level of your project with the following content:

version: '3.1'

services:

php-fpm:
build:
context: ./app
dockerfile: docker/php-fpm/Dockerfile
volumes:
- ./app:/app:rw,cached
environment:
APP_ENV: dev
DB_NAME: db
DB_HOST: db
DB_PORT: 3306
DB_USER: user
DB_PASS: pass
DB_DRIVER: pdo_mysql
nginx:
build:
context: ./app
dockerfile: docker/nginx/Dockerfile
environment:
PHP_HOST: php-fpm
volumes:
- ./app:/app:rw,cached
ports:
- 8000:80
db:
image: mariadb
restart: always
environment:
MYSQL_DATABASE: db
MYSQL_USER: user
MYSQL_PASSWORD: pass
MYSQL_RANDOM_ROOT_PASSWORD: '1'

This docker-compose setup will handle 3 containers:

  • one for your php application (php-fpm)
  • one for the web server (nginx)
  • one for your database (here mysql but adapt to your need)

The environment variables DB_NAME, DB_USER, DB_PASS... will be provided to the php container to let it access the database correctly. It's up to you to setup correctly this section in the Symfony application for example with this configuration in app/config/packages/doctrine.yml:

doctrine:
dbal:
dbname: '%env(resolve:DB_NAME)%'
host: '%env(resolve:DB_HOST)%'
port: '%env(resolve:DB_PORT)%'
user: '%env(resolve:DB_USER)%'
password: '%env(resolve:DB_PASS)%'
driver: '%env(resolve:DB_DRIVER)%'

Let’s start !

docker-compose up

After building each container image, you will be able to access the web application on http://localhost:8000. Because the source directory is mounted as volume in each docker container, you will be able to change a file and see it directly on your running local instance.

You can start by creating a first controller named MyController:

docker-compose exec php-fpm bin/console make:controller

And changing the routing in app/src/Controller/MyController.php:

#[Route('/', name: 'my')]
public function index(): Response

You have a working environment to develop your Symfony 5 application !

Part 2 — Build CDK application

Now the goal is to build a CDK application that will deploy your Symfony application. You will build the application step by step, in order to understand why each component is needed. You will rely on constructs if possible to speed-up the construction. This tutorial is built using the TypeScript flavor, but you can choose another programming language to write your CDK project. You must have installed node / npm and the cdk toolkit on your computer for this section.

A warning: albeit this is a working example, you will have to adapt the following to your needs. For example you may have specific requirements regarding backup retention, multi-AZ availability. Verify every parameter and do not apply the settings suggested without proper checks!

Let’s init a new project:

cd symfony_cdk/
mkdir symfony-app/
cd symfony-app/
cdk init app --language typescript
mv {*,.*} ../
cd ..
rm -rf symfony-app/

This is a trick to bypass the fact cdk doesn’t let you create a project in an non-empty folder. But for the purpose of the project it’s the better organization:

symfony_cdk/
app/ (Symfony app)
bin/ (CDK stack to create)
symfony-app.ts
lib/ (Stack definition)
symfony-app-stack.ts
test/ (Stack tests)
cdk.json (CDK parameters)
docker-compose.yml (to run locally your environment)
...

Let’s start by adding some dependencies!

cd symfony_cdk
npm add @aws-cdk/aws-applicationautoscaling @aws-cdk/aws-certificatemanager @aws-cdk/aws-ec2 @aws-cdk/aws-ecs @aws-cdk/aws-ecs-patterns @aws-cdk/aws-iam @aws-cdk/aws-logs @aws-cdk/aws-rds @aws-cdk/aws-route53

Note that it’s a good practice to keep all aws-cdk dependencies with the exact same version ( 1.131.0 here). If not you can experience some strange error (like wrong class signature).

Then you can start editing the file lib/symfony-app-stack.ts that will define the content of the stack.

Let’s build first a SymfonyAppProps interface that will help to define properties in each stack you will create:

interface SymfonyAppProps extends cdk.StackProps {
readonly dev: boolean;
}

The dev boolean will let you configure the stack for a dev or a production environment.

Then edit the constructor signature to use your interface instead of the generic props?: cdk.StackProps (no ? sign will require a props later):

constructor(scope: cdk.Construct, id: string, props: SymfonyAppProps) {

You will reuse the default VPC for this tutorial, so let’s identify it:

// Default VPC
const vpc = Vpc.fromLookup(this, "Vpc", {
isDefault: true,
});

Then create a RDS database:

// Database
const db = new DatabaseInstance(this, "Database", {
removalPolicy: props.dev ? cdk.RemovalPolicy.DESTROY : cdk.RemovalPolicy.SNAPSHOT,
multiAz: false,
engine: DatabaseInstanceEngine.mariaDb({
version: MariaDbEngineVersion.VER_10_5,
}),
// optional, defaults to m5.large
instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO),
allocatedStorage: 5,
storageType: StorageType.STANDARD,
deleteAutomatedBackups: props.dev,
vpc,
publiclyAccessible: false,
vpcSubnets: {
subnetType: SubnetType.PUBLIC,
},
databaseName: "db",
credentials: Credentials.fromGeneratedSecret("db_user"),
});

if (!db.secret) {
throw new Error("No Secret on RDS database");
}

There are a lot of parameters but VSCode helps a lot with the code completion. It will allow you to define correctly what you need as a database, and which backup ( backupRetention). The AWS CDK documentation is very helpful, for example: DatabaseInstance.

Then you will build the containers for your application:

const cluster = new Cluster(this, "Cluster", { vpc });

const taskDefinition = new FargateTaskDefinition(this, "TaskDefinition", {
cpu: 512,
memoryLimitMiB: 1024,
});

const logging = new AwsLogDriver({
streamPrefix: "symfony-app",
logGroup: new LogGroup(this, "LogGroup", {
removalPolicy: RemovalPolicy.DESTROY,
retention: RetentionDays.ONE_MONTH,
}),
});

/**
* This one serves on internet
*/
const nginxContainer = new ContainerDefinition(this, "nginx", {
image: ContainerImage.fromAsset(path.resolve(__dirname, "..", "app"), {
file: "docker/nginx/Dockerfile",
}),
taskDefinition,
logging,
environment: {
PHP_HOST: "localhost",
},
});

nginxContainer.addPortMappings({
containerPort: 80,
});

const image = ContainerImage.fromAsset(path.resolve(__dirname, "..", "app"), {
file: "docker/php-fpm/Dockerfile",
});

const phpContainer = new ContainerDefinition(this, "php", {
image,
taskDefinition,
logging,
environment: {
// set the correct Symfony env
APP_ENV: "prod",
// set the correct DB driver
DB_DRIVER: "pdo_mysql",
},
secrets: {
DB_USER: Secret.fromSecretsManager(db.secret, "username"),
DB_PASS: Secret.fromSecretsManager(db.secret, "password"),
DB_HOST: Secret.fromSecretsManager(db.secret, "host"),
DB_NAME: Secret.fromSecretsManager(db.secret, "dbname"),
DB_PORT: Secret.fromSecretsManager(db.secret, "port"),
},
});

This is building an ECS cluster and 2 ContainerDefinition to handle the 2 docker containers. This is a nice feature of CDK, it allows you to build on the fly the docker image by using the ContainerImage.fromAsset() method. This is particularly useful when you have both your app and the CDK app in the same git repository. There is no extra step to publish the docker image to an image repository, CDK is taking care of that for you. We are also building the FargateTaskDefinition and an AwsLogDriver object to handle logging.

Then finally let’s take care of the ECS Fargate service (Fargate is the ECS flavor with no EC2 instances to take care of, it’s highly recommended!):

// get the hostedZone
const hostedZone = HostedZone.fromLookup(this, "Zone", {
domainName: "mydomain.com",
});

// full domain Name
const domainName = "app.mydomain.com";

// create the https certificate
const certificate = new DnsValidatedCertificate(this, "SiteCertificate", {
domainName,
hostedZone,
region: cdk.Aws.REGION,
});

// then create the ALB and Fargate Service HTTPS
const application = new ApplicationLoadBalancedFargateService(this, "Service", {
cluster,
certificate,
domainName,
domainZone: hostedZone,
taskDefinition,
// how many tasks do you want to run ?
desiredCount: 1,
propagateTags: PropagatedTagSource.SERVICE,
redirectHTTP: true,
// following is needed as we are on a public subnet.
// https://stackoverflow.com/questions/61265108/aws-ecs-fargate-resourceinitializationerror-unable-to-pull-secrets-or-registry
assignPublicIp: true,
});

You rely here on Route53 to build a custom https certificate for the host app.mydomain.com (adapt for your case!). It's very handy because it doesn't require you to manually validate the DNS creation or the HTTPS certificate creation. All is managed for you, it just requires you to have the domain already managed by Route53. Finally the construct ApplicationLoadBalancedFargateService is assembling all the needed objects (tasks, ECS cluster, etc) with a Application Load Balancer.

Some adjustments are needed:

application.targetGroup.configureHealthCheck({
healthyHttpCodes: "200,307",
interval: Duration.minutes(5),
});

db.connections.allowDefaultPortFrom(application.service);

First one is to redefine the Health Check as the / is providing a 307 http code and not a classic 200 http code. Second one will allow the ECS service to speak to the RDS database.

You finally need to define the stack you want to create in the file bin/symfony-app.ts:

const app = new cdk.App();
new SymfonyAppStack(app, "SymfonyAppDevStack", {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */

/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },

/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
dev: true,
});

You can deploy your stack using:

cdk deploy SymfonyAppDevStack

Then after a 10 or 15mn (that’s not very a quick deployment, you’re right!):

✅  SymfonyAppDevStack

Outputs:
SymfonyAppDevStack.ServiceLoadBalancerDNSEC5B149E = Symfo-Servi-HF9KA6EOZCEW-1111280096.eu-central-1.elb.amazonaws.com
SymfonyAppDevStack.ServiceServiceURL250C0FB6 = https://app.mydomain.com

Stack ARN:
arn:aws:cloudformation:eu-central-1:126096613559:stack/SymfonyAppDevStack/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxx

And it’s up!

You can find the complete example repository here:

Don’t forget to tear down your stack after testing to avoid unexpected AWS charges:

cdk destroy SymfonyAppDevStack

Conclusion

Once you have a Symfony application, it’s quite easy to build an adapted infrastructure using CDK. It allows you to quickly deploy from the command line or better from a CI/CD process like Github Actions or equivalent. It will allow you to test your deployment on a testing stack, with identical configuration before shipping in production. The release process will also be much easier and predictable. Even better: it will be now possible to automate production or test release based on a specific branch management — for example configure your CI to release your application on a dev environment when a commit is done on main.

--

--

Julien Bras
Wiiisdom Labs

Innovation Team Leader @ Wiiisdom. Love testing & using new things. Dad of 3.