terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } random = { source = "hashicorp/random" version = "~> 3.0" } } } provider "aws" { region = "ca-central-1" } locals { # CONFIGURATION my_ip = "70.36.57.88/32" # REPLACE WITH YOUR IP domain_name = "db.es.imex.online" hosted_zone_name = "imex.online" # The root zone you manage in Route53 region = "ca-central-1" app_name = "esdp-hasura" } # ----------------------------------------------------------------------------- # 1. SECRETS & CREDENTIALS (Auto-generated) # ----------------------------------------------------------------------------- # Generate a random username resource "random_string" "db_username" { length = 8 special = false numeric = false # PG usernames shouldn't start with numbers usually upper = false } # Generate a secure random password resource "random_password" "db_password" { length = 24 special = true override_special = "!#$%&*()-_=+[]{}<>:?" # Safe chars for Postgres } # Generate a random secret for Hasura Admin resource "random_password" "hasura_admin_secret" { length = 32 special = false } # Create the Secret Container in AWS Secrets Manager resource "aws_secretsmanager_secret" "hasura_credentials" { name = "${local.app_name}-credentials-${random_string.suffix.result}" } resource "random_string" "suffix" { length = 6 special = false upper = false } # Store the connection string and admin secret # We wait for the DB to be created so we can grab the endpoint resource "aws_secretsmanager_secret_version" "creds" { secret_id = aws_secretsmanager_secret.hasura_credentials.id secret_string = jsonencode({ # Hasura needs the full connection URL database_url = "postgres://${random_string.db_username.result}:${random_password.db_password.result}@${aws_db_instance.default.endpoint}/esdp" admin_secret = random_password.hasura_admin_secret.result }) } # ----------------------------------------------------------------------------- # 2. NETWORKING # ----------------------------------------------------------------------------- resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support = true tags = { Name = "${local.app_name}-vpc" } } resource "aws_internet_gateway" "gw" { vpc_id = aws_vpc.main.id } # Get available AZs in the configured region data "aws_availability_zones" "available" { state = "available" } # Public Subnets (ALB + Public DB + Fargate) resource "aws_subnet" "public_a" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = data.aws_availability_zones.available.names[0] map_public_ip_on_launch = true } resource "aws_subnet" "public_b" { vpc_id = aws_vpc.main.id cidr_block = "10.0.2.0/24" availability_zone = data.aws_availability_zones.available.names[1] map_public_ip_on_launch = true } resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.gw.id } } resource "aws_route_table_association" "a" { subnet_id = aws_subnet.public_a.id route_table_id = aws_route_table.public.id } resource "aws_route_table_association" "b" { subnet_id = aws_subnet.public_b.id route_table_id = aws_route_table.public.id } # ----------------------------------------------------------------------------- # 3. SECURITY GROUPS # ----------------------------------------------------------------------------- resource "aws_security_group" "alb_sg" { name = "${local.app_name}-alb-sg" vpc_id = aws_vpc.main.id # Allow HTTPS from anywhere ingress { protocol = "tcp" from_port = 443 to_port = 443 cidr_blocks = ["0.0.0.0/0"] } # Allow HTTP for redirection ingress { protocol = "tcp" from_port = 80 to_port = 80 cidr_blocks = ["0.0.0.0/0"] } egress { protocol = "-1" from_port = 0 to_port = 0 cidr_blocks = ["0.0.0.0/0"] } } resource "aws_security_group" "ecs_sg" { name = "${local.app_name}-ecs-sg" vpc_id = aws_vpc.main.id # Only accept traffic from ALB ingress { protocol = "tcp" from_port = 8080 to_port = 8080 security_groups = [aws_security_group.alb_sg.id] } egress { protocol = "-1" from_port = 0 to_port = 0 cidr_blocks = ["0.0.0.0/0"] } } resource "aws_security_group" "db_sg" { name = "${local.app_name}-db-sg" vpc_id = aws_vpc.main.id # 1. Allow ECS Tasks ingress { protocol = "tcp" from_port = 5432 to_port = 5432 security_groups = [aws_security_group.ecs_sg.id] } # 2. Allow YOUR IP (Strict Access) ingress { protocol = "tcp" from_port = 5432 to_port = 5432 cidr_blocks = [local.my_ip] } egress { protocol = "-1" from_port = 0 to_port = 0 cidr_blocks = ["0.0.0.0/0"] } } # ----------------------------------------------------------------------------- # 4. DATABASE (RDS Postgres t4g.micro) # ----------------------------------------------------------------------------- resource "aws_db_subnet_group" "default" { name = "${local.app_name}-db-subnets" subnet_ids = [aws_subnet.public_a.id, aws_subnet.public_b.id] } resource "aws_db_instance" "default" { identifier = "${local.app_name}-db" engine = "postgres" engine_version = "17.6" instance_class = "db.t4g.micro" allocated_storage = 20 storage_type = "gp3" db_name = "esdp" username = random_string.db_username.result password = random_password.db_password.result publicly_accessible = true vpc_security_group_ids = [aws_security_group.db_sg.id] db_subnet_group_name = aws_db_subnet_group.default.name skip_final_snapshot = true apply_immediately = true deletion_protection = true } # ----------------------------------------------------------------------------- # 5. DOMAIN & SSL (ACM + Route53) # ----------------------------------------------------------------------------- data "aws_route53_zone" "main" { name = local.hosted_zone_name private_zone = false } resource "aws_acm_certificate" "cert" { domain_name = local.domain_name validation_method = "DNS" lifecycle { create_before_destroy = true } } # Create DNS Record for Validation resource "aws_route53_record" "cert_validation" { for_each = { for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => dvo } allow_overwrite = true name = each.value.resource_record_name records = [each.value.resource_record_value] ttl = 60 type = each.value.resource_record_type zone_id = data.aws_route53_zone.main.zone_id } # Wait for Validation to complete resource "aws_acm_certificate_validation" "cert" { certificate_arn = aws_acm_certificate.cert.arn validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] } # Point Domain to ALB resource "aws_route53_record" "www" { zone_id = data.aws_route53_zone.main.zone_id name = local.domain_name type = "A" alias { name = aws_lb.main.dns_name zone_id = aws_lb.main.zone_id evaluate_target_health = true } } # ----------------------------------------------------------------------------- # 6. LOAD BALANCER (ALB) # ----------------------------------------------------------------------------- resource "aws_lb" "main" { name = "${local.app_name}-alb" internal = false load_balancer_type = "application" security_groups = [aws_security_group.alb_sg.id] subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id] } resource "aws_lb_target_group" "hasura" { name = "${local.app_name}-tg" port = 8080 protocol = "HTTP" vpc_id = aws_vpc.main.id target_type = "ip" health_check { path = "/healthz" matcher = "200" interval = 60 } } # HTTP Listener (Redirect to HTTPS) resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.main.arn port = "80" protocol = "HTTP" default_action { type = "redirect" redirect { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } } # HTTPS Listener resource "aws_lb_listener" "https" { load_balancer_arn = aws_lb.main.arn port = "443" protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-2016-08" certificate_arn = aws_acm_certificate_validation.cert.certificate_arn default_action { type = "forward" target_group_arn = aws_lb_target_group.hasura.arn } } # ----------------------------------------------------------------------------- # 7. ECS FARGATE (Compute) # ----------------------------------------------------------------------------- # IAM Role for Task Execution (Needs permission to pull images + read secrets) resource "aws_iam_role" "ecs_execution_role" { name = "${local.app_name}-execution-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ecs-tasks.amazonaws.com" } }] }) } # Attach basic ECS policy resource "aws_iam_role_policy_attachment" "ecs_execution_basic" { role = aws_iam_role.ecs_execution_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } # Attach Secrets Manager Read Policy resource "aws_iam_policy" "secrets_policy" { name = "${local.app_name}-secrets-policy" policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = [aws_secretsmanager_secret.hasura_credentials.arn] }] }) } resource "aws_iam_role_policy_attachment" "ecs_secrets_attach" { role = aws_iam_role.ecs_execution_role.name policy_arn = aws_iam_policy.secrets_policy.arn } # CloudWatch Logs Policy for log group creation resource "aws_iam_policy" "cloudwatch_logs_policy" { name = "${local.app_name}-cloudwatch-logs-policy" policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "arn:aws:logs:${local.region}:*:log-group:/ecs/${local.app_name}*" }] }) } resource "aws_iam_role_policy_attachment" "ecs_logs_attach" { role = aws_iam_role.ecs_execution_role.name policy_arn = aws_iam_policy.cloudwatch_logs_policy.arn } resource "aws_ecs_cluster" "main" { name = "${local.app_name}-cluster" } resource "aws_ecs_task_definition" "hasura" { family = "${local.app_name}-task" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = 256 # 0.25 vCPU memory = 512 # 0.5 GB execution_role_arn = aws_iam_role.ecs_execution_role.arn container_definitions = jsonencode([ { name = "hasura" image = "hasura/graphql-engine:v2.48.10" essential = true portMappings = [{ containerPort = 8080 }] # INJECT SECRETS HERE secrets = [ { name = "HASURA_GRAPHQL_DATABASE_URL" valueFrom = "${aws_secretsmanager_secret.hasura_credentials.arn}:database_url::" }, { name = "HASURA_GRAPHQL_ADMIN_SECRET" valueFrom = "${aws_secretsmanager_secret.hasura_credentials.arn}:admin_secret::" } ] environment = [ { name = "HASURA_GRAPHQL_ENABLE_CONSOLE", value = "true" }, # Set false for strict prod, ] logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = "/ecs/${local.app_name}" "awslogs-region" = local.region "awslogs-stream-prefix" = "ecs" "awslogs-create-group" = "true" } } } ]) } resource "aws_ecs_service" "hasura" { name = "${local.app_name}-service" cluster = aws_ecs_cluster.main.id task_definition = aws_ecs_task_definition.hasura.arn desired_count = 1 launch_type = "FARGATE" network_configuration { subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id] security_groups = [aws_security_group.ecs_sg.id] assign_public_ip = true } load_balancer { target_group_arn = aws_lb_target_group.hasura.arn container_name = "hasura" container_port = 8080 } } # ----------------------------------------------------------------------------- # 8. AUTO SCALING # ----------------------------------------------------------------------------- resource "aws_appautoscaling_target" "ecs_target" { max_capacity = 3 min_capacity = 1 resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.hasura.name}" scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" } resource "aws_appautoscaling_policy" "ecs_policy_cpu" { name = "scale-cpu" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.ecs_target.resource_id scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension service_namespace = aws_appautoscaling_target.ecs_target.service_namespace target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = 70.0 scale_in_cooldown = 60 scale_out_cooldown = 60 } } # ----------------------------------------------------------------------------- # OUTPUTS # ----------------------------------------------------------------------------- output "hasura_console_url" { value = "https://${local.domain_name}/console" } output "db_public_endpoint" { value = aws_db_instance.default.endpoint } output "secrets_arn" { value = aws_secretsmanager_secret.hasura_credentials.arn }