Deploying a Symfony 4/5 Application on AWS Fargate (part 1)

Jérémy Levy
The Scaffold blog
Published in
9 min readJul 14, 2020

Hello 👋,

In this series of articles we will see how to create and deploy a production-ready Symfony application on AWS Fargate.

In the first part, we will create a fully-working application with some controllers, some entities, some forms, some views, some tests, some assets and some migrations.

In the second part, we will see how to create all the components required to deploy our application on AWS (CI/CD, auto-scaling, load-balancing, SSL, CDN...).

Step 1 — Creating our application

To create our application, we will use the Symfony CLI. If you haven’t installed it yet, go to the Symfony website and follow the installation instructions.

Once the CLI is installed, run the following command to create a Symfony 5 application:

$ symfony new my_app --full

or this one to create a Symfony 4 application:

$ symfony new my_app --version=4.4 --full

A new folder my_app will be created.

Step 2 — Embedding our application in Docker

To ease the deployment process, we’ve chosen to embed our application in a Docker container.

We’ve created a repository that includes all the files that we will need. Start by cloning it in your application folder:

$ git clone https://github.com/getrevolv/revolv-docker-architecture-symfony-starter-pack.git clone-tmp && mv ./clone-tmp/{*,.docker,.dockerignore} . && rm -rf clone-tmp---.docker/
xdebug.ini
symfony.dev.ini
symfony.prod.ini
supervisord.conf
php-fpm.conf
nginx.conf
entrypoint.test.sh
entrypoint.sh
... your app files ....dockerignore
Dockerfile
Dockerfile.dev
docker-compose.ci.yml
docker-compose.test.yml
docker-compose.override.yml
docker-compose.yml

As you can see, nothing fancy here:

You could safely fine-tune these services by updating the configuration files in the .docker folder.

Now, run your application:

$ docker-compose up

If everything was set up correctly, your application will be running at http://localhost:9000.

Congrats! 🎉

Step 3 — A simple CRUD

For the purpose of this article, we will create a simple CRUD for a fictitious “Product” entity.

First, we will create the Product entity using the Maker bundle:

$ docker-compose exec app php bin/console make:entityClass name of the entity to create or update (e.g. OrangePizza):
> Product
New property name (press <return> to stop adding fields):
> name
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> no
Add another property? Enter the property name (or press <return> to stop adding fields):
>
(press enter again to finish)

Now that we have a Product class configured and ready to save, we need to generate and execute a migration to create an associated product table:

$ docker-compose exec app php bin/console make:migration$ docker-compose exec app php bin/console doctrine:migrations:migrateWARNING! You are about to execute a database migration that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no)
> yes

Great! You now have a Product entity with an associated table in your database. Let’s generate a complete CRUD for our new entity:

$ docker-compose exec app php bin/console make:crud ProductSuccess!Next: Check your new CRUD by going to /product/

Perfect!

Given that we only have one entity in our application we will remove the /product prefix from our routes.

Open the Product controller and replace the @Route("/product") annotation:

<?php// .src/Controller/ProductController.php// .../**
* @Route("/") <--- replace here
*/
class ProductController extends AbstractController

Finally, let’s see our application in action: http://localhost:9000

Woo-hoo! 🙌

Check that everything works as expected by adding some products!

[0] https://symfony.com/doc/current/doctrine.html

Step 4 — Adding tests

Even if we don’t have written any code, we want to make sure, for the purpose of this article, that the generated CRUD doesn’t contain any bug.

Before writing our first test, we will need to install some packages for unit and functional testing, so here we go:

$ docker-compose exec app composer require --dev symfony/phpunit-bridge symfony/browser-kit symfony/css-selector dama/doctrine-test-bundle doctrine/doctrine-fixtures-bundleDo you want to execute this recipe?
[y] Yes
[n] No
[a] Yes for all packages, only for the current installation session
[p] Yes permanently, never ask again for this project
(defaults to n): y

To ensure that our functional tests are independent from each other, we’ve installed the DAMADoctrineTestBundle bundle.

This bundle needs to be activated as a PHPUnit extension in the phpunit.xml.dist file:

<!-- ./phpunit.xml.dist --><phpunit>
<!-- ... -->
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit>

Now, we need to create some fixtures for our Product entity:

$ docker-compose exec app php bin/console make:fixturesThe class name of the fixtures to create (e.g. AppFixtures):
> ProductFixture

Once created, we will need to add some code to the generated class. Open the ProductFixture.php file, then update it as follow:

<?php// ./src/DataFixtures/ProductFixture.phpnamespace App\DataFixtures;use App\Entity\Product;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class ProductFixture extends Fixture
{
public function load(ObjectManager $manager)
{
$product = new Product();

$product->setName('An awesome test product');
$manager->persist($product); $manager->flush();
}
}

Next, create a test file for our Product repository:

<?php// ./src/tests/Repository/ProductRepositoryTest.phpnamespace App\Tests\Repository;use App\Entity\Product;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ProductRepositoryTest extends KernelTestCase
{
/**
* @var \Doctrine\ORM\EntityManager
*/
private $entityManager;
protected function setUp()
{
$kernel = self::bootKernel();
$this->entityManager = $kernel->getContainer()
->get('doctrine')
->getManager();
}
public function testSearchByName()
{
$product = $this->entityManager
->getRepository(Product::class)
->findOneBy(['name' => 'An awesome test product']);
$this->assertSame('An awesome test product', $product->getName());
}
protected function tearDown(): void
{
parent::tearDown();
$this->entityManager->close();
$this->entityManager = null;
}
}

And a test file for our Product controller:

<?php// ./src/tests/Controller/ProductControllerTest.phpnamespace App\Tests\Controller;use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;class ProductControllerTest extends WebTestCase
{
public function testProductIndex()
{
$client = static::createClient();
$client->request('GET', '/'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertSelectorTextContains('html h1', 'Product index'); }
}

Finally, run your tests by executing the following command:

$ docker-compose -f docker-compose.yml -f docker-compose.test.yml run app ./vendor/bin/simple-phpunit

If everything was set up correctly, your tests will pass:

Congrats! 🎉

[0] https://symfony.com/doc/current/testing.html

[1] https://symfony.com/doc/current/testing/database.html

Step 5 — Adding assets

Symfony 4+ comes with a new bundle named Encore for packaging, versioning and minifying your JavaScript and CSS assets.

To start using it, you need to install the required bundle and the associated Yarn packages. We’ll also install Bootstrap and jQuery:

$ docker-compose exec app composer require symfony/webpack-encore-bundle$ docker-compose exec app yarn install$ docker-compose exec app yarn add bootstrap jquery popper.js sass-loader@^8.0.0 node-sass --dev

Next, you need to update your base layout as follow:

{# templates/base.html.twig #}<!DOCTYPE html>
<html>
<head>
<!-- ... -->

{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
</head>
<body>
<!-- ... -->

{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
</body>
</html>

Encore is configured via a webpack.config.js file at the root of your project. To include Bootstrap in our project, we need to enable the Sass pre-processor:

// ./webpack.config.jsEncore
// ...
+ .enableSassLoader()

// ...
;
// ...

Then, we need to replace the generated app.css file with a new app.scss:

// ./assets/css/app.css -> ./assets/css/app.scss// ~ is a reference to node_modules
@import "~bootstrap/scss/bootstrap";
body {
max-width: 600px;
margin: 80px auto;
}

We also need to replace the reference in app.js:

// ./assets/js/app.jsimport '../css/app.scss'; // <--- replace here// ...

Great! 👌 The next step is to configure our application to use Bootstrap styles when rendering forms.

# ./config/packages/twig.yamltwig:
# ...
form_themes: ['bootstrap_4_layout.html.twig']

Finally, we dump our assets:

$ docker-compose exec app yarn encore dev

Let’s see our application in action now: http://localhost:9000

Woo-hoo! 🎉

[0] https://symfony.com/doc/current/frontend.html

[1] https://symfony.com/doc/current/frontend/encore/bootstrap.html

[2] https://symfony.com/doc/current/form/form_themes.html

Step 6 — Sessions & Logs

So far we have a fully-working application with a database, some migrations, some tests and some assets. What could be missing?

In an auto-scaled / load-balanced environment, like the one we will create on AWS, your requests will be served by instances with ephemeral filesystem that could be created and destroyed depending on traffic.

To avoid losing any data, we need to make sure that our sessions and our logs are not saved directly on the filesystem.

To do this, we will save our sessions in our database and we will send our logs to the standard output.

All that’s required to send our logs to the standard output is changing the Monolog configuration for production environment:

# ./config/packages/prod/monolog.ymlmonolog:
handlers:
# ...
nested:
type: stream
path: "php://stderr" # <--- replace here
# ...
# ...

Locate each section in this file that uses a stream logger, and change the value of path to "php://stderr".

Saving the sessions in our database is a bit more tedious. First, we need to register a new handler service with our database credentials:

# ./config/services.yamlservices:
# ...
Symfony\ Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'

Then, we need to tell Symfony to use this service as the session handler using the handler_id configuration option:

# ./config/packages/framework.yamlframework:
session:
# ...
handler_id: Symfony\
Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler

Now, we need to generate a migration to create the sessions table:

$ docker-compose exec app php bin/console doctrine:migrations:generate

In the generated migration class, add the following code in the up and down methods:

<?php// ./src/Migrations/VersionXXXXXXXXXX.php// ...final class VersionXXXXXXXXXX extends AbstractMigration
{
// ...
public function up(Schema $schema) : void
{
// ...
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('CREATE TABLE `sessions` (`sess_id` VARBINARY(128) NOT NULL PRIMARY KEY, `sess_data` BLOB NOT NULL,`sess_lifetime` INTEGER UNSIGNED NOT NULL,`sess_time` INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB;'); } public function down(Schema $schema) : void
{
// ...
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('DROP TABLE sessions'); }
}

Then, run your migrations:

$ docker-compose exec app php bin/console doctrine:migrations:migrateWARNING! You are about to execute a database migration that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no)
> yes

Perfect! Now, we will check that everything is successfully configured. To do this, we’ll try to add some property in session.

In your Product controller, add the following code:

<?php// ./src/Controller/ProductController.php// ...use Symfony\Component\HttpFoundation\Session\SessionInterface;class ProductController extends AbstractController
{
// ...
public function index(ProductRepository $productRepository, SessionInterface $session): Response
{
$session->set('foo', 'bar');

// ...
}

// ...
}

Go to your products page at http://localhost:9000, then check that a new line was inserted in your database:

$ docker-compose exec app php bin/console doctrine:query:sql 'SELECT * FROM sessions'...array (size=1)

Hooray!! 🎉

[0] https://symfony.com/doc/current/session/database.html

Step 7 — Load balancer and user’s IP address

In a load-balanced environment your users will not talk directly to your instances.

Instead, they will send their requests to your load-balancer that will forward them to the available instances.

For the most part, this doesn’t cause any problems with Symfony. But, when a request passes through a load balancer, certain request information, like the user’s IP address, will not be there where Symfony expects them.

If we don’t tell Symfony to look for these values elsewhere, we’ll get incorrect information about the client’s IP address, whether or not the client is connecting via HTTPS, the client’s port and the hostname being requested.

To avoid that, we need to tell Symfony to “trust” our load-balancer. To do this, update your index.php as follow:

// ./public/index.php// ...if ($_SERVER['APP_ENV'] === "prod") {
Request::setTrustedProxies(
['127.0.0.1', 'REMOTE_ADDR'],
Request::HEADER_X_FORWARDED_AWS_ELB
);
}
// ...

If you are also using a reverse proxy on top of your load balancer (e.g. CloudFront) […] You also need to append the IP addresses or ranges of any additional proxy (e.g. CloudFront IP ranges) to the array of trusted proxies.

[0] https://symfony.com/doc/current/deployment/proxies.html

Conclusion

Congrats! You now have a fully working Symfony application, ready to be deployed on AWS! 🎉

See you soon for the second part!

Love 🖤

--

--

Jérémy Levy
The Scaffold blog

29. Software engineer & entrepreneur. Creator of @eleven-sh @recode-sh @scaffold-sh. Always building. 🇫🇷