Back to Web Servers guides

Deploying Docker Containers to AWS ECR/ECS (Beginner's Guide)

Marin Bezhanov
Updated on July 12, 2024

In today's fast-paced world of software development, the ability to quickly package, deploy, and scale applications on public cloud infrastructure has become more essential than ever. Containerization has truly revolutionized these processes, with Docker standing out as a catalyst in the advancement of this movement. Docker containers can provide consistent runtime environments for your applications, allowing developers to build, deploy, and iterate with unprecedented efficiency.

Containers make a lot of sense in the public cloud. The cloud offers a powerful and versatile platform for provisioning infrastructure, so enterprises no longer have to plan their infrastructure requirements well ahead of their current needs. Instead, they can quickly scale their resources up and down as necessary. Moreover, big cloud providers offer various services that can easily integrate with containerized applications, making it even easier for organizations to reduce their operational burdens.

There are many cloud providers out there, but Amazon Web Services (AWS) undoubtedly stands out as the current market leader. Services such as Amazon Elastic Compute Cloud (EC2), Amazon Relational Database Service (RDS), and Amazon Elastic Container Service (ECS) can drastically simplify the orchestration, scaling, and management of Docker containers, making it easier for users to deploy their containerized applications on AWS reliably and securely.

This article will guide you through several possible methods of deploying your containerized applications on AWS. You'll start by preparing the Docker images for your application containers and setting up the necessary infrastructure (provisioning a relational database and configuring networking settings). You'll then explore a valid but laborious method for deployment based on EC2 instances and auto-scaling groups. This will give you the fundamentals needed for understanding the more advanced deployment method presented in this tutorial, which shows you how to deploy your containers on a serverless platform using AWS ECS.

By the end of this tutorial, you'll have a much deeper understanding of how serverless environments like AWS ECS operate and will be well-equipped to deploy your containerized applications on cloud infrastructure.

Without further ado, let's get this journey started!

Prerequisites

  • Good understanding of Docker images and containers for local development.
  • Prior experience using Linux for basic system administration tasks.
  • Access to an AWS account to provision the required services and infrastructure.

Please note that setting up a domain name and configuring HTTPS for your applications are not going to be covered in this tutorial. They are, however, an essential part of deploying applications in a production environment, so remember to research them separately to solidify your knowledge.

Preparing your Docker images

In this tutorial, you'll work with one of the demo applications created for an earlier tutorial (Building Production-Ready Docker Images for PHP Apps). You don't need to be familiar with that tutorial to complete this section, as all the necessary steps for building the relevant Docker images will be outlined here too, but feel free to review it if you'd like to obtain some additional information.

The demo application is called the Product API. The Product API provides a REST API allowing users to perform simple CRUD operations (creating, retrieving, updating, and deleting) against some fictional product database. It requires a web server to accept incoming HTTP connection requests and forward them to the PHP runtime for execution, as well as a database for storing the product information.

In the PHP world, the web server and the PHP runtime usually live in two separate containers, so this section will show you how to set up the images for them both.

Preparing a PHP image

Let's begin by preparing the PHP image. You'll create two distinct flavors of this image: a production version that you can use for deployment and a development version that you can use for generating some test data in your database.

Clone the product-api repository locally to obtain the application source code, and cd into its folder:

 
git clone https://github.com/betterstack-community/product-api.git
 
cd product-api

Create a new Dockerfile and populate it with the following contents:

Dockerfile
FROM composer:2.7.6 AS composer
FROM php:8.3.7-fpm-alpine3.19

# Install required PHP extensions.
RUN docker-php-ext-install pdo_mysql

# Copy application source code to image.
COPY --chown=www-data:www-data . /var/www/html

# Install Composer packages.
COPY --from=composer /usr/bin/composer /usr/bin/composer
USER www-data
ARG COMPOSER_NO_DEV=1
ENV COMPOSER_NO_DEV=$COMPOSER_NO_DEV
RUN composer install

# Reset main user.
USER root

To build the production image, run:

 
docker build -t product-api:1.0.0 .

To build the development image, run:

 
docker build --build-arg COMPOSER_NO_DEV=0 -t product-api:1.0.0-dev .

The only difference between the production and the development image is that the latter contains some additional development tools and packages necessary for seeding the database with dummy data. If you have followed the instructions correctly, both images should now be available locally:

 
docker image ls product-api
Output
REPOSITORY    TAG         IMAGE ID       CREATED         SIZE
product-api   1.0.0-dev   bbfb1f00ef55   3 minutes ago   171MB
product-api   1.0.0       22a92001add3   4 minutes ago   121MB

With that, you can proceed with preparing the web server image.

Preparing a web server image

As already mentioned, the product-api container expects a web-server container to run in front of it. The web-server container accepts all incoming HTTP requests, translates them to FastCGI, and proxies them to the product-api container for execution.

Preparing the web-server image is quite straightforward. You can use NGINX as a base image and add a custom configuration file on top of it to specify the correct settings for properly translating and redirecting requests to the product-api container.

Create a new folder named web-server and cd into it:

 
mkdir web-server
 
cd web-server

Create a new file named nginx.conf and populate it with the following contents:

nginx.conf
server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /usr/share/nginx/html;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;

    charset utf-8;

    location / {
        try_files $uri /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        root /var/www/html/public;
        fastcgi_pass localhost:9000;
        fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

This file specifies a default virtual server configuration block that captures all incoming HTTP requests in the web-server container. It also provides the necessary instructions for NGINX to recognize and forward PHP requests to the PHP-FPM FastCGI server running inside the product-api container (this assumes that the web-server container can reach the product-api container at localhost:9000).

Create a new Dockerfile and populate it with the following contents:

Dockerfile
FROM nginx:1.26.0-alpine-slim

COPY ./nginx.conf /etc/nginx/conf.d/default.conf

To build this image, run:

 
docker build -t web-server:1.0.0 .

If you have followed the instructions correctly, the web-server image should now be available locally:

 
docker image ls web-server
Output
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
web-server   1.0.0     97764447cae7   9 seconds ago   17.1MB

With that, you have all the Docker images necessary for deploying the Product API application on AWS.

Creating a database

A quick note before getting right into creating your first managed database on AWS. Throughout the examples that follow, I'll often refer to your AWS account ID (<AWS_ACCOUNT_ID>) and region (<AWS_REGION>). Both of them will show up on many of the included screenshots and code snippets as:

  • 123456789012 for the account ID.
  • eu-north-1 (Stockholm) for the region.

Make sure to change these accordingly to reflect your account ID and region settings. With that out of the way, let's go ahead and create a database for the Product API.

The database is where the Product API stores all of its product information. While you can run the database as a single Docker container for local development, a production environment typically needs a much more robust solution.

One possible solution could be to provision a few AWS EC2 Linux servers and install the database software yourself, but think about the operational overhead of having to configure backups, set up replication, manage load balancing, and perform regular version upgrades while continuously monitoring and ensuring that your entire setup works normally. This could quickly go out of hand, and that's exactly the problem that a managed database service, such as AWS RDS, solves.

With RDS, AWS manages everything for you. The database clusters it provisions have automated backups and failover configured right out of the box. The operating systems and database distributions powering the underlying database servers receive regular security patches and updates, and you can easily scale resources such as CPU, RAM, and storage from a convenient web interface rather than having to manually provision and setup Linux machines on your own.

In RDS, you usually interact with your database through a unique service endpoint (i.e., a special hostname that points to some form of load balancer or proxy that sits in front of your database servers), which can intelligently route traffic to your primary server or one of its read replicas (if such exists). You don't have to worry about setting up any load balancing software or DNS records yourself, as AWS already does that for you.

Of course, this is best illustrated with an example, so let's go ahead and provision a new database cluster in RDS.

Provisioning a database

In the AWS Console, find the Relational Database Service (RDS):

find RDS service

Click Create database:

create database

Select a database engine. The Product API requires a MySQL-compatible database, so you can set the Engine type to Aurora (MySQL Compatible):

select database engine

Aurora is a MySQL replacement developed internally at AWS that offers certain performance and scalability advantages over traditional MySQL databases. This is mainly due to the mechanism it uses to store and replicate data. It is typically faster for performing failover, storage expansion, and crash recovery, and it usually costs a bit less to operate.

Next, scroll down to the Templates section and choose a template corresponding to your requirements. The Production defaults are generally a good starting point, but if you just want to click around and explore without incurring a huge charge, you might want to select the Dev/Test template instead, which will only add a single instance to your database cluster:

select database template

In a production setting, of course, always make sure that your cluster contains at least two database instances located in two different availability zones. That way, if one instance goes down, the other one can take over.

Scroll down to the Settings section and specify a database cluster identifier (e.g., tutorial). Then, under Credential Settings choose the Self managed option and check the Auto generate password option:

modify database settings

The database cluster identifier will give your cluster a unique name that can be used to distinguish it from other RDS clusters running in this particular region of your AWS account. As for the database credentials, the AWS Secrets Manager option can provide an added level of security for production databases (bear in mind that it incurs some additional charges as well, though), but the self-managed option makes it easier to explore RDS without having to involve another AWS service.

For some additional cost savings while testing, you may scroll down to the Instance configuration section and opt for one of the burstable database instance classes, such as db.t3.medium:

choose database instance class

Bear in mind, though, that for real production workloads, you'll be much better off with one of the memory optimized classes, as they come with larger amounts of memory, better networking, and more consistent CPU performance, so your databases will be operating a lot faster on them.

Next, scroll down to the Connectivity section, find the Public access option and choose Yes to allow public access to your cluster:

enable public access

Once you get acquainted with AWS, you'll find that leaving Public access disabled is a better choice for increasing the security of your production databases and protecting them from unauthorized access. However, the tradeoff here has been made consciously to let you access the database cluster directly from your local machine, rather than having to set up AWS Site-to-Site VPN or AWS Client VPN, or using an EC2 instance to act as an SSH proxy, which will result in a lot of added complexity.

Take note of the VPC security group assigned to your cluster (named default). This group determines the firewall settings for your database. Right after launching the cluster, you'll have to tweak it a little bit so traffic from your local machine can flow through to the database:

default VPC security group

With all of this done, leave everything else at its default settings, scroll down to the bottom of the page, and click Create database:

create database

Provisioning the cluster may take a while, but at some point you'll see a flash message indicating that the cluster was created successfully. When this happens, go ahead and press the View connection details button:

view connection details

Write down the connection details and store them somewhere safe, as you'll need them later to connect to your database. When you're ready, click Close:

connection details

At this point, ensure that both the database cluster (tutorial) and the database instance (tutorial-instance-1) appear as Available. If the instance is not Available, you won't be able to connect to the cluster and interact with it:

database instance status

The only thing left is to add your public IP address to the default security group that regulates network access to your database cluster.

In the AWS console, find the Security groups feature:

security groups

Select the default security group, and click Edit inbound rules:

default security group

Click Add rule:

add rule

Specify MYSQL/Aurora as the Type and My IP as the Source:

save rules

Now is also a good time to add a rule that allows any resources deployed in your private network on AWS (the AWS VPC) to access this database as well. Click the Add rule button once again, then specify MYSQL/Aurora as the Type and Custom as the Source, inputting the CIDR range 172.31.0.0/16. This CIDR range corresponds to all private IP addresses allocated within your default AWS virtual private network.

When you're ready, click Save rules:

save rules

With these rules added, database traffic coming from your local machine will be allowed to pass through the firewall, and other services deployed in your AWS account will also be able to reach the database.

You can now connect to the database using your preferred database client. You'll do this in the next section.

Connecting to the database

It's time to test the database connection and create a user for the application, and a database schema that it can write to and interact with. You may use any client you like, but I prefer DBeaver.

Open up DBeaver and click the New Database Connection button:

new database connection

Select the MySQL database driver and click Next:

select database driver

Enter the Server Host, Username, and Password that correspond to the connection details that you obtained earlier by clicking the View connection details button:

DBeaver connection settings

Navigate to the Driver properties tab and set the allowPublicKeyRetrieval setting to true:

DBeaver driver properties

Otherwise, you might get the following error when trying to connect:

 
Public Key Retrieval is not allowed

Finally, click Test Connection:

test connection

You should see the following response:

connection successful

The connection seems to be working, so you can click Finish:

finish setup

It's time to create a user and a schema for the application. Double-click on the name of the connection to connect to the database:

double-click connection name

After connecting, expand the Databases section and then the Users section. You'll notice two things:

  1. There are no other database schemas besides sys.
  2. There are no other users besides your initial admin user and some system users created by AWS for internal usage.

databases and users sections

The Product API application needs both a dedicated database user and a clean database schema that it can work with.

Click the Open SQL Script button to execute a new set of SQL statements against your database:

open sql script

Paste the following SQL statements into the command window:

 
CREATE DATABASE product_api;
CREATE USER 'product_api'@'%' IDENTIFIED BY 'test123';
GRANT ALL ON product_api.* TO 'product_api'@'%';

These will create a new schema called product_api and a user named product_api (with password test123) with full access to that schema.

Click the Execute SQL Script button to execute everything:

execute sql script

You should see a similar result:

sql script result

With that, the user and the schema are now created, and you can proceed with configuring the Product API to communicate with the database using the specified credentials.

Populating the database

The schema that you created is currently empty, but the Product API requires certain database tables to function properly. You can address this by running database migrations. Here, for the sake of simplicity, you'll run the migrations locally from your machine, which is only possible because you enabled public access to your database earlier on.

For small, non-critical applications that you're solely responsible for, this can be considered an acceptable approach. For larger applications, of course, it's much better to automate the migration process through a CI/CD pipeline, thus reducing the chance of human error and tightening the security of your system.

As you already have the product-api images prepared locally, running the migrations is a matter of executing a specific php command inside a short-lived container launched from the product-api:1.0.0 image (the precise command is php artisan migrate). This container must also be made aware of the relevant database connection details. You can pass them to the container in the form of environment variables.

Create a new file named db.env and populate it with the following contents:

db.env
DB_HOST=tutorial.cluster-cb08aaskslz3.eu-north-1.rds.amazonaws.com
DB_USERNAME=product_api
DB_PASSWORD=test123
DB_DATABASE=product_api

Then run the following command:

 
docker run -it --rm --env-file db.env product-api:1.0.0 php artisan migrate

An interactive prompt appears, asking you to confirm your request:

confirm migration

Select Yes and hit Enter. Soon after, the migration process begins, creating the necessary tables in the specified database schema:

run migration

The application now has everything necessary to boot up. However, to make this a little more interesting, you may want to add some dummy data to the newly created tables. This isn't something you'd be doing in a real production environment, but it is helpful to have some data to work with in this tutorial.

You can use the product-api:1.0.0-dev image for that purpose, as it includes all the development dependencies required for generating test data. You have to execute the command php artisan db:seed, as follows:

 
docker run -it --rm --env-file db.env product-api:1.0.0-dev php artisan db:seed ProductSeeder

Once again, a prompt appears asking you to confirm your request:

confirm seeding

After you do, the database is seeded with test data:

seed database

With that, the database is fully prepared for integration with your application.

Pushing to a container registry

The product-api and web-server images are currently stored on your local machine, but to launch containers from them in the cloud, AWS should be able to pull them from a more accessible location, such as a remote container registry.

You could certainly use something familiar, such as Docker Hub, but you'll find that AWS ECR (Elastic Container Registry) integrates a lot better with other AWS services (such as EC2 and ECS), offering faster download speeds, easier authentication, and lower infrastructure costs.

The next steps will walk you through setting up the necessary private image repositories for uploading your custom images to ECR.

Setting up private repositories

Find the Elastic Container Registry (ECR) service in the AWS console:

find ECR service

Under Private registry, choose Repositories from the menu on the left:

choose Repositories

Click Create repository:

start creating repository

You have to set up two repositories here. One for the product-api image and one for the web-server image.

Start with the product-api. Specify a Repository name in the corresponding input field:

specify repository name

Take note of the URL prefix. ECR repositories in AWS have the following URL format:

 
<AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/<REPOSITORY_NAME>

For my account ID (123456789012) and region (eu-north-1), the product-api repository becomes available at:

 
123456789012.dkr.ecr.eu-north-1.amazonaws.com/product-api

Leave all other options at their default settings, scroll down to the bottom of the screen, and click Create repository:

finish creating repository

Repeat the same procedure for the web-server repository.

In the end, your private registry should have the following repositories:

private registry repositories

This allows you to push the product-api and web-server images to ECR, but you must configure your local Docker client to authenticate with AWS first. This requires issuing an authorization token. There are several possible ways to obtain one, but using the AWS CLI with an IAM user is probably the easiest.

Creating an IAM user

Find the Identity and Access Management (IAM) service in the AWS console:

find IAM service

Choose Users from the menu on the left:

select users

Click Create user:

click create user

Specify a username (e.g., docker) and click Next:

specify username

The docker user will be utilized solely for allowing your local machine to access your AWS account so you can push images to ECR.

Once again, if your application is relatively small and you're the main person responsible for its deployment, this may be considered a suitable choice. If, however, you're working on a larger project, it would make far more sense to automate the entire image building and publishing process, integrating it into your CI/CD pipelines. It's not only going to be much more efficient, but it will also increase the security of your system and reduce the chances of human error.

On the next page, select the Attach policies directly option:

attach policies directly

Scroll down to the Permissions policies section, and select the AmazonEC2ContainerRegistryPowerUser policy from the list of available policies. Then click Next:

attach AmazonEC2ContainerRegistryPowerUser policy

The AmazonEC2ContainerRegistryPowerUser is one of the AWS-managed security policies. AWS-managed policies are predefined permission sets designed by Amazon to support the most typical use cases and scenarios in their cloud. The AmazonEC2ContainerRegistryPowerUser policy, in particular, allows IAM users to read and write to private repositories and issue the corresponding authorization tokens.

After clicking Next, you're prompted to confirm the creation of your new user account. Click Create user to do that:

create user

A message appears confirming the operation. Click the View user button to continue further:

flash message

This leads you to the user management page, where you have to click Create access key:

create access key

The access key allows you to authenticate with the AWS CLI in order to obtain ECR authorization tokens from your local machine.

A lot of options appear on the page that follows. These options all direct you towards more secure alternatives for granting access to your AWS account instead of using a static access key. Indeed, the suggested alternatives could be better suited for a production setting, but for the sake of simplicity, choose Other and click Next:

click other and next

On the next screen, specify a description to remind you what this user account will be used for, then click Create access key:

specify a description

Copy the generated access and secret access key and store them somewhere safe. You'll need them in a bit to configure your local Docker client to authenticate with AWS. Bear in mind that after navigating away from this page, you'll no longer be able to retrieve the secret access key, so failing to copy it now will render your credentials useless!

Once you're ready, click Done:

copy secret and access key

Using the generated key pair, you can now configure your local Docker client to authenticate with AWS to push your custom images to ECR.

Configuring the AWS CLI

The AmazonEC2ContainerRegistryPowerUser policy that you attached to your IAM user grants you permission to request (and receive) valid ECR authorization tokens on behalf of your user through the AWS API. Your local Docker client can then use these tokens to authenticate with AWS and push your custom images to ECR.

The easiest way to interact with the AWS API from your local machine is through the AWS CLI. As I generally like to keep my local Linux installation clean and organized, I prefer to run the AWS CLI through a Docker container rather than installing it directly on my machine. This is more convenient, as it helps me avoid potential conflicts with other dependencies on my system.

Setting up the AWS CLI locally is quite straightforward, using the following command:

 
docker run --rm -it -v awscli:/root/.aws public.ecr.aws/aws-cli/aws-cli:2.15.42 configure

There are a couple of interesting things to note here:

  • The --rm option ensures that the container is removed after it finishes running the specified command.
  • The -it option enables interactive input.
  • The -v awscli:/root/.aws option creates a new local volume named awscli (mounted as /root/.aws inside the container) where your AWS CLI credentials and configuration files persist for subsequent command invocations.
  • The public.ecr.aws/aws-cli/aws-cli:2.15.42 reference points to the official AWS CLI v2 image supplied by Amazon, which contains the AWS CLI installation with all of its mandatory dependencies.
  • The configure command starts the initial AWS CLI configuration process, where you are prompted to enter your access key, secret access key, default region, and output format preferences, and the respective credential and configuration files are created as a result.

Running this command results in a similar flow:

configure AWS CLI

Here, you should supply the keys you created earlier in IAM for the docker user. Optionally, you may also specify your default region.

Bear in mind: even though the secret access key is displayed in plain text on the screenshot above, this is only done to make the example clearer. You should never share your secret access keys in plain text with anyone. They are a sensitive piece of information that should be kept secure at all times!

With that out of the way, you're ready to request an ECR token for your Docker client.

Obtaining an ECR token

The AWS CLI command for obtaining an ECR token is aws ecr get-login-password. That command returns a base64-encoded string that you can pass to the docker login command in order to authenticate your local client with ECR. Note that this token is only valid for the next 12 hours, after which you'll have to issue a new one.

The complete authentication command goes like this:

 
docker run --rm -it -v awscli:/root/.aws public.ecr.aws/aws-cli/aws-cli:2.15.42 ecr get-login-password --region <AWS_REGION> | docker login --username AWS --password-stdin <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com

Upon successful execution, you'll get a similar result:

Output
WARNING! Your password will be stored unencrypted in /home/marin/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded

Do note that passing in the authorization token directly to docker login is a quick and easy way to get started. Using a credential helper is a lot more secure, but it requires installing additional software packages, and that's beyond the scope of the tutorial.

You're now ready to use your local Docker client to push your custom images to AWS.

Pushing images to ECR

At this point, you should have the following images on your local machine:

 
docker image ls product-api
Output
REPOSITORY    TAG         IMAGE ID       CREATED       SIZE
product-api   1.0.0-dev   bbfb1f00ef55   6 hours ago   171MB
product-api   1.0.0       22a92001add3   6 hours ago   121MB
 
docker image ls web-server
Output
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
web-server   1.0.0     97764447cae7   5 hours ago   17.1MB

Pushing these images to ECR requires tagging them with the appropriate repository URL. As you remember, the format is:

 
<AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/<REPOSITORY_NAME>

To tag them, execute the following commands, replacing <AWS_ACCOUNT_ID> and <AWS_REGION> with the actual values corresponding to your AWS account:

 
docker tag product-api:1.0.0 <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/product-api:1.0.0
 
docker tag web-server:1.0.0 <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/web-server:1.0.0

You can now push these images to ECR by executing the following commands:

 
docker push <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/product-api:1.0.0
 
docker push <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/web-server:1.0.0

Assuming you have followed the instructions correctly, both images should now be available in your private registry:

product-api image on ECR

web-server image on ECR

Revoking credentials

After successfully uploading your images to ECR, you may want to revoke the credentials that you used to guarantee the security of your account.

You can begin by issuing:

 
docker logout <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com
Output
Removing login credentials for 123456789012.dkr.ecr.eu-north-1.amazonaws.com

This will remove any traces of the ECR authorization token from your local /home/<username>/.docker/config.json file.

Next, you can delete the awscli Docker volume:

 
docker volume rm awscli

This will erase the AWS access key and secret access key stored on your local machine.

Finally, you can remove the entire user account that you created earlier in the AWS console.

remove user account

Alternatively, you may keep the account but revoke its access key. However, this account will not be used anymore in the tutorial, so it's better to stick with the former option. These steps will ensure that the risk of unauthorized access to your AWS resources is reduced to an absolute minimum.

You're now ready to deploy the Product API service on AWS infrastructure.

Deploying to AWS EC2

Running containers directly on a Linux VM is a common method for deploying Docker images in production. On AWS, Linux VMs are known as EC2 instances. An EC2 instance is basically a virtual Linux server that you can access over SSH to install packages and execute commands.

While this is a perfectly valid deployment method, you'll find that it involves a decent amount of manual work, and there are some easier alternatives available in the form of serverless platform-as-a-service (PaaS) solutions, such as AWS ECS. Nevertheless, exploring it is very useful, as it provides a great understanding of how ECS simplifies the deployment process.

The next few sections will show you how to launch a new EC2 instance and configure it for deploying the containers you made earlier.

Launching an EC2 instance

Find the EC2 service in the AWS console:

find EC2 service

From the menu on the left, navigate to Instances:

navigate to Instances

Click Launch instances:

launch instances

Set the instance name to product-api:

set instance name

Scroll down to the Application and OS Images section and pick Debian:

choose AMI

Debian will provide a familiar environment that's easy to install Docker in.

Scroll down, locate the Key pair (login) section, and click Create new key pair:

create new key pair

Specify product-api as the Key pair name and click Create key pair:

create new key pair

When prompted to do so, save the generated private key file to your local filesystem (mine goes at /home/marin/Downloads/product-api.pem):

save key pair

Make sure to change the ownership of the downloaded file on your local machine. Otherwise, ssh commands will complain that the file permissions are too open and refuse to initiate connections to your server.

 
chmod 0600 ~/Downloads/product-api.pem

Scroll down to the Network settings section, and allow SSH traffic for your IP, as well as HTTP traffic from the Internet:

network settings

Leave everything else at its default settings, scroll down to the bottom of the page, and click Launch instance:

launch instance

Soon after, a message appears, confirming the successful launch of your instance. Click on the instance identifier:

instance identifier

This takes you to a listing, where you'll be able to find the IP address of your newly created instance:

instance public IP

You can use that IP address and the private key that you downloaded earlier to connect to your instance by executing the following command:

 
ssh -i ~/Downloads/product-api.pem admin@51.20.124.12

Once you're logged in, you should see a familiar Bash prompt:

Output
Linux ip-172-31-21-122 6.1.0-10-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.37-1 (2023-07-03) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
admin@ip-172-31-21-122:~$

Installing Docker

An EC2 instance comes with a clean Linux install, and to be able to launch any Docker containers on it, you'll have to set up the Docker engine yourself. You can follow the official installation instructions, which I've included below for convenience:

 
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

You can run the hello-world image, as suggested in the official documentation, to verify that the installation is successful:

 
sudo docker run --rm hello-world
Output
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
c1ec31eb5944: Pull complete
Digest: sha256:a26bff933ddc26d5cdf7faa98b4ae1e3ec20c4985e6f87ac0973052224d24302
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.
. . .

It seems like the Docker engine works correctly, so you can proceed with pulling the Product API images to the EC2 instance.

Creating an IAM role

Earlier, you used a specially created IAM user (named docker) for pushing your custom images to ECR. Later, you deleted that user to maintain the security of your AWS account. You may now be wondering how the EC2 instance will be able to pull any images when that user is no longer available.

AWS provides a convenient mechanism for letting your EC2 instances automatically authenticate with other services running on your AWS infrastructure. This is done through IAM roles, and the following steps will show you how to set up one.

Find the Identity and Access Management (IAM) service in the AWS console:

find IAM service

Click Roles from the menu on the left:

click roles

Click Create role:

click create role

Specify AWS service as the Trusted entity type:

specify trusted entity type

Scroll down to the Use case section, pick EC2, then click Next:

specify use case

A trusted entity basically determines which resource types are allowed to assume the role that you are creating. In this case, you're specifying EC2 as the trusted entity, which means that all of your EC2 instances will be able to use this role for accessing other resources in your AWS account.

On the next screen, find the AmazonEC2ContainerRegistryReadOnly managed policy and attach it to your role, then click Next:

attach AmazonEC2ContainerRegistryReadOnly policy

Unlike the AmazonEC2ContainerRegistryPowerUser, the AmazonEC2ContainerRegistryReadOnly policy only allows pulling images from your private registry. That way, you can ensure that your EC2 instances can't do any harm by making modifications to your images.

Next, specify a name for your role (e.g., product-api) and add a description that indicates its purpose:

specify name and description

Finally, scroll down to the bottom of the page and click Create role:

create role

After a moment, a message confirms the successful creation of your IAM role:

create role flash message

You can now attach the role to your EC2 instance.

Attaching an IAM role

Navigate back to the EC2 instances dashboard and select the product-api instance:

select product-api instance

From the Actions menu, navigate to Security and click Modify IAM role:

modify IAM role

Specify the product-api IAM role that you created earlier as the preferred IAM role, then click Update IAM role:

update IAM role

A message confirms the operation:

role attached flash message

Keep in mind that it may take a few seconds before the role attachment propagates to your EC2 instance. All of this information is then reflected in the instance metadata describing the EC2 instance. The instance metadata is a rich set of information (unique to every individual EC2 instance) that describes its properties as they are stored in the internal inventory systems of AWS.

The instance metadata can be accessed from within the EC2 instance itself through a special HTTP endpoint (http://169.254.169.254/latest/meta-data).

To do that, SSH into your instance:

 
ssh -i ~/Downloads/product-api.pem admin@51.20.124.12

Then request an authorization token from the metadata service and assign it to a shell variable (TOKEN) for use in subsequent commands:

 
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 1200")

Finally, send a request to the metadata service using the generated token to list the available metadata categories:

 
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data

You'll see a ton of interesting metadata endpoints that you can use to obtain various pieces of information about your EC2 instance:

Output
ami-id
ami-launch-index
ami-manifest-path
block-device-mapping/
events/
hostname
iam/
identity-credentials/
instance-action
instance-id
instance-life-cycle
instance-type
local-hostname
local-ipv4
mac
metrics/
network/
placement/
profile
public-hostname
public-ipv4
public-keys/
reservation-id
security-groups
services/
system

The AWS CLI uses these endpoints to automatically obtain temporary security credentials (i.e., an access key ID and a secret access key). This enables the CLI to authenticate with your AWS account and perform the operations permitted by the attached IAM role.

Since you created your EC2 instance from the official Debian AMI (Amazon Machine Image), the AWS CLI is already available on your Linux VM, which you can verify by executing:

 
aws --version
Output
aws-cli/2.9.19 Python/3.11.2 Linux/6.1.0-10-cloud-amd64 source/x86_64.debian.12 prompt/off

To retrieve an ECR authorization token for the Docker client installed on your EC2 instance, you can then run:

 
aws ecr get-login-password | docker login --username AWS --password-stdin <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com

This will lead to a similar output:

Output
WARNING! Your password will be stored unencrypted in /home/admin/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded

With that, you can finally pull the images from ECR to your EC2 instance.

Launching Docker containers

Go ahead and pull the images to your EC2 instance by running the following commands:

 
sudo docker pull <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/product-api:0.1.0
 
sudo docker pull <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/web-server:0.1.0

Instead of launching them manually, you can create a simple compose.yaml file, populated with the following contents:

compose.yaml
x-common-settings: &common-settings
  restart: always
  network_mode: host

services:
  web-server:
    <<: *common-settings
    image: 381491990672.dkr.ecr.eu-north-1.amazonaws.com/web-server:0.1.0
  product-api:
    <<: *common-settings
    image: 381491990672.dkr.ecr.eu-north-1.amazonaws.com/product-api:0.5.0
    environment:
      - APP_KEY=base64:a2nQ3bQFHbjU50y1oeeaNxfFpDCsF5t4egS/zEiY5lQ=
      - DB_HOST=tutorial.cluster-cb08aaskslz3.eu-north-1.rds.amazonaws.com
      - DB_USERNAME=product_api
      - DB_PASSWORD=test123
      - DB_DATABASE=product_api

This compose.yaml file defines two services named web-server and product-api, and an interesting x-common-settings fragment.

The x-common-settings fragment defines the following settings that are inherited by both services:

  • restart: always ensures that the web-server and product-api containers are automatically restarted in case they terminate unexpectedly or in case the Docker daemon restarts on the VM (e.g., after a system reboot).
  • network_mode: host places the network stack of both containers directly on the host machine. In other words, their network is not isolated from the host, and port forwarding is not needed. This generally results in optimized network performance, but the real reason for doing it is because it allows the web-server container to reach product-api on localhost:9000, which is identical to how both containers see each other when deployed with AWS ECS.

The environment settings for the product-api have been taken directly from the db.env file you created earlier. The only exception is APP_KEY, where you're hard coding a dummy encryption key that the PHP application requires for booting up.

At this point, you can finally launch the containers:

 
sudo docker compose up -d

To verify that the application works, open up a browser and input the public IP address of your EC2 instance in the address bar. You should see a page listing the five fictional products that you generated earlier with the artisan db:seed command:

api products

Scaling your deployment

The application is now running and can be publicly accessed over HTTP. Depending on its size and the amount of traffic it receives, a single EC2 instance may suffice for a while, but what if you need to handle more traffic or ensure high availability?

In that case, you may want to consider implementing auto-scaling groups and using an application load balancer to distribute traffic evenly across multiple EC2 instances for increased performance and reliability.

Let's briefly glance over this topic.

Auto-scaling groups are created from another resource called launch templates. You may think of launch templates as blueprints for spinning new EC2 instances. Creating one requires creating an AMI (Amazon Machine Image) from your customized EC2 instance. You can then use that AMI to define a launch template.

Navigate back to your EC2 instance dashboard, select the product-api instance, and then from Actions, navigate to Image and templates and click Create image:

create image

Specify a name for your image (e.g., product-api):

specify image name

Leave everything else at its default settings, scroll down to the bottom of the page, and click Create image:

create image

A message appears indicating that the operation has been accepted for execution:

AMI flash message

Creating the image takes some time and your EC2 instance is restarted before it completes. This allows AWS to capture an accurate snapshot of the storage volume attached to the instance.

You can find out whether the AMI has finished creating by navigating to the AMIs page:

navigate to AMI

There, select the product-api AMI and observe its Status. When the AMI is ready, status should show as Available:

AMI status

As soon as the AMI status becomes Available, you can navigate to the Launch Templates page:

navigate to launch templates

There, click the Create launch template button:

create launch template

On the next page, find the Application and OS Images (Amazon Machine Image) section, select the My AMIs tab, restrict the options to Owned by me, and select the product-api AMI you created earlier:

application and os images

Scroll down to the Instance type section and set the instance type to t3.micro:

set instance type

In general, you should specify an instance type capable of meeting the resource requirements of your application. Since the Product API has very modest requirements, the t3.micro will suffice for this tutorial (but would be rarely useful for most real-world applications).

Scroll down a bit further and find the Key pair (login) section. There, choose the product-api pair that you created earlier:

specify key-pair

Each EC2 instance in the auto-scaling group will utilize that key-pair, which means that you'll be able to SSH into each instance in the group if you need to.

Scroll down a bit more and find the Network settings section. There, choose Select existing security group and specify the launch-wizard-1 as the security group to be attached to your EC2 instance:

specify security group

The launch-wizard-1 group was created during the initial launch of the original product-api EC2 instance that you made an AMI from. It allows SSH traffic from your public IP address, and HTTP traffic from the Internet.

Leave everything else at its default settings, scroll down to the bottom of the page, and click Create launch template:

create launch template

A message confirms the creation:

flash message

Navigate to Auto Scaling Groups from the menu on the left:

auto-scaling groups

Click Create Auto Scaling group:

create auto-scaling group

Specify a name for your auto-scaling group and select product-api as the Launch template:

choose launch template

Scroll down to the bottom of the page and click Next:

choose launch template

At the Choose instance launch options step, enable all Availability Zones and click Next:

choose launch template

At the Configure advanced options step, find the Load balancing section and select Attach to a new load balancer:

attach to a new load balancer option

A new section named Attach to a new load balancer appears. There, set the Load balancer type to Application Load Balancer, the Load balancer name to product-api, and the Load balancer scheme to Internet-facing.

attach to a new load balancer section

A little further down, find the Listeners and routing setting, select Create a target group, and set the New target group name to product-api:

create a target group
Scroll down to the very bottom of the page and click Next:

click next

These configurations will automatically create two new resources in your AWS account: an application load balancer and a target group. The application load balancer opens up a new public endpoint for receiving HTTP requests directed towards your application. The target group then specifies every EC2 instance that the load balancer may forward traffic to.

The next step is titled Configure group size and scaling. The Group size section allows you to specify the exact number of EC2 instances that will be launched initially in your auto-scaling group through the Desired capacity setting:

desired capacity

Further down, the Scaling section allows you to specify the auto-scaling criteria:

scaling section

The Scaling limits (Min desired capacity and Max desired capacity) determine the minimum and maximum amount of EC2 instances that will be launched in your AWS account as part of this auto-scaling group. The Automatic scaling - optional section allows you to enable a Target tracking scaling policy through which you could specify a Metric type (such as Average CPU utilization) based on which auto-scaling events will be triggered (and EC2 instances will be launched or terminated to match the demand).

Scroll to the very bottom of the page and click Skip to review to fast-track to the final step of the process (as none of the remaining steps contain anything essential):

skip to review

The final Review step allows you to confirm your configuration settings:

review step

Just scroll down to the bottom of the page and click Create Auto Scaling group:

create auto scaling group

Provisioning the auto-scaling group, the initial EC2 instance, the application load balancer, and the respective target group will take a bit of time, so please wait for everything to complete.

When everything is ready, you'll see 1 instance reported as running in the product-api auto-scaling group:

product-api auto-scaling group

Expand the menu on the left and find the Load Balancers link to navigate to the load balancers overview page:

load balancers link

From the Load balancers page, navigate to the product-api load balancer:

product-api load balancer

On its page, you'll find the DNS name pointing to your load balancer:

load balancer DNS name

Paste that address in your browser, and you should see a listing of the test products you created earlier with the artisan db:seed command:

product listing

Your auto-scaling group works, but as you can see, setting everything up was a long and tedious process. Let's clean up the resources you created so far and then explore a much more convenient method for deploying Docker containers on AWS.

Cleaning up

The next section will present a much easier method for deploying your application on AWS. But before moving on, please go ahead and remove all the resources listed below, as they won't be needed anymore, and some of them will also conflict with resources that you're about to create in the next section:

  1. product-api auto-scaling group.
  2. product-api application load balancer.
  3. product-api target group.
  4. product-api AMI.
  5. product-api EBS snapshot.
  6. product-api launch template.

Follow the provided order, as deleting a resource may require one of the earlier resources to be removed first (e.g., you can't delete the target group before removing the load balancer).

When you're ready, you can move on to the next section of the tutorial.

Deploying to AWS ECS

As you can see, deploying your images directly to an EC2 instance, while sparing you a lot of low-level infrastructure details, still involves a lot of work. Wouldn't it be great to just upload your images to AWS and let them handle everything else for you? That's where a serverless platform-as-a-service (PaaS) solution such as AWS ECS can help.

ECS allows you to specify the URL to your Docker images and let the platform do all the heavy lifting for you. You don't need to provision any Linux VMs and load balancers yourself. There are no IAM roles to set up, no launch templates to create, no auto-scaling groups to configure, and no Docker Engine to install. AWS handles everything for you automatically!

Let's see how this works.

Creating a task definition

Find the Elastic Container Service (ECS) in the AWS console:

find ECS service

Select Task definitions from the menu on the left:

select task definitions

In AWS, a task definition allows you to supply ECS with detailed information about the workload you're intending to deploy.

Click Create new task definition:

create new task definition

Specify product-api as the Task definition family:

specify task definition family

The task definition family provides a unique name to distinguish the present and future versions of your task definition (modifying an existing task definition results in a new version being created within the same task definition family).

Don't modify anything related to the Infrastructure requirements; just make sure that AWS Fargate is checked:

AWS Fargate

AWS Fargate is the serverless compute platform that allows you to run containers without managing any underlying infrastructure and spinning up your own EC2 instances.

Scroll down to the Container - 1 section and start filling in the required details. Specify web-server as the Name and <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/web-server:1.0.0 as the Image URI:

configure container 1

Don't be confused by the Private registry authentication toggle switch:

private registry authentication

Even though you are using Docker images hosted in your private registry on ECR, ECS is already capable of authenticating with it automatically. There are no IAM users to create or IAM roles to set up. Enabling this option is only necessary if you are pulling private images from an external registry (such as Docker Hub). This is an excellent example of why choosing ECR over Docker Hub was a good idea; it integrates much better with other AWS services, saving you additional time and effort.

You may leave everything else for Container - 1 at its default settings. Scroll further down and find the Add container button:

add container

Click on it and a new section named Container - 2 will appear. Fill in the Name and Image URI like you did for Container - 1, but this time use product-api as the name and <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/product-api:1.0.0 as the image:

configure container 2

Unlike Container - 1, however, Container - 2 requires one additional setting. You have to expose its FastCGI port to localhost, so the web-server container can reach the product-api container on localhost:9000.

For that purpose, click the Add port mapping button:

add port mapping

Then input 9000 in the Container port field:

map port 9000

Leave everything else for Container - 2 at its default settings, scroll down to the very bottom of the page, and click the Create button:

create task definition

With that, your task definition is almost ready for deployment:

task definition created

The only thing left is to create a Fargate cluster that you can deploy it on.

Creating a cluster

Deploying a task definition requires a cluster. A cluster abstracts away the infrastructure required for running your Docker containers.

To create one, navigate to Clusters from the menu on the left:

navigate to clusters

Click Create cluster:

create cluster

Specify tutorial as the Cluster name:

specify cluster name

Make sure that the cluster uses AWS Fargate as its Infrastructure component:

specify cluster infrastructure

Leave everything else at its default settings, scroll down to the very bottom of the page, and click Create:

create cluster

Creating the cluster takes some time, so be patient and wait for the process to complete:

cluster created

Once the cluster is created, you can proceed with deploying your task definition onto it.

Creating a service

With your cluster created, click on View cluster:

view cluster

This will take you to an overview page with a lot of details about the cluster. There, find the Services section and click the Create button to initiate the deployment of a new service:

create service

You can provide configuration for your service using the form that appears next:

create service form

Scroll down, find the Deployment configuration section, and set the task definition Family to product-api:

specify task definition family

Scroll down a little further and find the Networking section. There, add launch-wizard-1 to the list of selected security groups. As you remember, the launch-wizard-1 security group allowed HTTP traffic from the internet to reach the application. Not selecting it here will result in your deployment not being able to receive incoming HTTP requests from external sources:

specify security group

Scroll down a little further and find the Load balancing section. There, set the Load balancer type to Application Load Balancer and the Load balancer name to product-api

specify load balancer name and type

This will automatically create an application load balancer to route traffic across your deployed product-api instances.

A little further down, change the Target group name to product-api and the Health check path to /api/products:

specify target group name and health check path

The health check path specifies a URI in your application that the application load balancer will periodically send requests to in order to validate whether it's healthy or not (any response code other than 200 is considered unhealthy). As the Product API doesn't have any specific health check endpoint, using the default /api/products URI is a suitable option.

Leave everything else at its default settings, scroll down to the very bottom of the page, and click Create:

create service

As with the creation of the Fargate cluster, the initial provisioning of everything required to start your service (load balancer, target group, Docker containers) would take a while, so be patient and wait for everything to come up. After it does, the product-api will appear in the list services running on the tutorial cluster (with Status reported as Active).

Go ahead and click on the Service name to continue further:

service list

Click View load balancer:

view load balancer

This takes you to the application load balancer page, where you can find the DNS name pointing to the application:

view load balancer

Paste the DNS name in the address bar of your browser, and you should see the application returning a response:

view app

Good job! The Product API is running in the cloud, and you didn't have to provision any EC2 instances or additional infrastructure by yourself. The ECS serverless approach results in highly reduced maintenance overhead and complexity, and you can see for yourself that, compared to the EC2 auto-scaling group approach, the deployment process is much simpler and easier to manage.

This marks the end of the tutorial.

Final thoughts

Congratulations on finishing this tutorial! You went through a lot of steps to get to this point, but you surely learned a lot in the process and should now be a lot more confident in utilizing AWS (and ECS in particular) in your future projects. If this was your first time exploring AWS, you must have learned a ton of new terms and concepts, which will help you navigate through its complexities and explore its services with greater confidence and ease.

I encourage you to continue exploring AWS further and experimenting with the different tools and solutions it can offer to keep improving your deployment and development processes. Some things worth trying are:

  • Setting up AWS Client VPN to access your cloud resources securely without exposing them to the public.
  • Using AWS Secrets Manager to store your database credentials and pass them to your applications.
  • Looking into AWS CodeBuild for automatically building and publishing your Docker images to ECR without using your local machine.
  • Disabling public access to your RDS instance and running your database migrations from an EC2 instance instead of your local machine.
  • Getting familiar with Route 53 and AWS ACM for setting up HTTPS for your application.
  • Learning more about VPC and security groups to make sure your cloud environment is properly secured and isolated.

Thanks for reading, and until next time!

Author's avatar
Article by
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working with Go, Java, PHP, and JS. He is passionate about exploring new technologies and staying up-to-date with the latest industry trends, and he loves sharing his knowledge through technical writing and teaching.
Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github