Terraform Code for Rails on AWS ECS.
It has a SIMPLE and SECURE architecture.
Rails Repository: reireias/rails-on-ecs
It is a simple architecture using CloudFront, ALB, ECS, and Aurora.
CloudFront is configured with WAF to block attacks such as XSS and SQLi.
The domains for CloudFront and ALB are managed by Route53.
All layers have Multi-AZ level availability, so the service can continue even if a failure occurs in any AZ.
Subnets within a VPC are subdivided by usage as shown above.
The Security Group is also designed to be separated for different purposes.
By specifying a different Security Group as the source of inbound rules between Security Groups, only the minimum amount of communication is allowed.
We also set up a VPC Endpoint so that we can communicate with S3 and ECR without going through the Internet.
The CD pipeline is run in CodePipeline, triggered by an image push to the ECR as shown above.
By triggering a push to the ECR, we can achieve a natural boundary of responsibility between Rails application developers and infrastructure engineers.
The image build is done on the GitHub Actions side.
rails db:migrate
is executed by CodeBuild.
Deployment is done using CodeDeploy, which switches all traffic to the new version at once, so that assets references do not cause 404 errors when deployed.
The following security measures have been implemented in accordance with the Well-Architected Framework and the best practices of the Security Hub.
- DDoS countermeasures by placing CloudFront at the front stage
- Blocking of XSS, SQLi, and other attacks through WAF managed rules
- Encryption settings for RDS
- Management of confidential information using the SSM parameter store
- Vulnerability scanning with ECR
- Security pointing by Terraform code static analysis with tfsec
The following logging settings are in place.
Logging should be done whenever possible. (Although this repository does not log WAFs.)
This repository uses a domain obtained from Route53, as in the implementation of route53.tf.
Even if you have acquired your domain in a different way, it is a good idea to transfer it so that you can configure it from Route53.
If you can set up your domain in Route53, you can smoothly implement ACM validation and so on like acm.tf.
Implementing ACM certificates and their validation DNS records:
resource "aws_acm_certificate" "main" {
domain_name = local.domain
subject_alternative_names = ["*.${local.domain}"]
validation_method = "DNS"
}
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = [for record in aws_route53_record.acm : record.fqdn]
}
Configure the following custom header in CloudFront.
resource "aws_cloudfront_distribution" "main" {
origin {
custom_header {
name = "x-pre-shared-key"
value = data.aws_kms_secrets.secrets.plaintext["cloudfront_shared_key"]
}
# ...
}
# ...
}
Configure the listener rules of the ALB so that only requests with the above header are sent to ECS.
resource "aws_lb_listener_rule" "app_from_cloudfront" {
listener_arn = aws_lb_listener.app.arn
priority = 100
action {
type = "forward"
target_group_arn = aws_lb_target_group.app["blue"].arn
}
condition {
http_header {
http_header_name = "x-pre-shared-key"
values = [data.aws_kms_secrets.secrets.plaintext["cloudfront_shared_key"]]
}
}
# NOTE: Ignore target group switch
lifecycle {
ignore_changes = [action]
}
}
By configuring it in this way, you can block the route for direct requests to the ALB without going through CloudFront.
When deploying Rails on multiple servers with rolling updates, there is a moment when references to some of the assets files such as js and css will result in a 404 error.
This happens when we get the html from the new server and go to get the js in it to the old server.
There are various ways to work around this problem, but here we simply use CodeDeploy and do not perform rolling updates to avoid it.
You can switch all requests to the new version at the same time by specifying deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
.
When you're in the early stages of building your product and you don't have a Rails application yet, you'll want a lightweight image that passes at least one health check and an ECS task definition that uses it.
By using the medpeer/health_check image, you can create an ECS task definition that only needs to pass the health check for the specified path.
resource "aws_ecs_task_definition" "app" {
# ...
# NOTE: Dummy containers for initial.
container_definitions = <<CONTAINERS
[
{
"name": "web",
"image": "medpeer/health_check:latest",
"portMappings": [
{
"hostPort": 3000,
"containerPort": 3000
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${aws_cloudwatch_log_group.app.name}",
"awslogs-region": "${local.region}",
"awslogs-stream-prefix": "web"
}
},
"environment": [
{
"name": "NGINX_PORT",
"value": "3000"
},
{
"name": "HEALTH_CHECK_PATH",
"value": "/health_checks"
}
]
}
]
CONTAINERS
}
In case AZs are added in the future, use for_each
for resources such as subnets that are created in the number of AZs.
For example, a public subnet can be created as follows.
resource "aws_subnet" "public" {
for_each = local.availability_zones
vpc_id = aws_vpc.main.id
availability_zone = each.key
cidr_block = cidrsubnet(local.vpc_cidr, 8, local.az_conf[each.key].index)
tags = {
Name = "${local.name}-public-${local.az_conf[each.key].short_name}"
}
}
Here, we define a variable named az_conf
as follows.
locals {
az_conf = {
"ap-northeast-1a" = {
index = 1
short_name = "1a"
}
"ap-northeast-1c" = {
index = 2
short_name = "1c"
}
"ap-northeast-1d" = {
index = 3
short_name = "1d"
}
}
}
By using the cidrsubnet function, you can effortlessly manage CIDRs with certain rules.
If you want a list of Subnet ids, you can generate it with the following code.
values(aws_subnet.public)[*].id
Confidential information can be encrypted with KMS and included in this repository for simple management.
By using aws_kms_secrets
, compounding will be done by Terraform at plan time and apply time.
The usage is as follows.
-
Encrypt with KMS key.
$ aws kms encrypt --key alias/terraform --plaintext "secret_value" --output text --query CiphertextBlob
-
Create
aws_kms_secrets
data resource.locals { secrets = { foo = "encrypted_value" } } # NOTE: register all secrets data "aws_kms_secrets" "secrets" { dynamic "secret" { for_each = local.secrets content { name = secret.key payload = secret.value } } }
-
Use decrypted value
data.aws_kms_secrets.secrets.plaintext["foo"] # set decrypted value