How to Map a Custom Domain to AWS Fargate Tasks Without Load Balancer for Cost Optimization


1 views

When running web applications on AWS Fargate, the fundamental issue with direct domain mapping stems from the ephemeral nature of Fargate tasks. Each deployment or scaling event can potentially change:

  • The public IP address assigned to the ENI
  • The underlying network interface configuration
  • The security group associations

Here's a step-by-step approach to achieve stable domain mapping without ALB/NLB:


# Create Elastic Network Interface (ENI)
aws ec2 create-network-interface \
  --subnet-id subnet-12345678 \
  --groups sg-12345678 \
  --description "Persistent ENI for Fargate tasks"

Key configuration parameters for your task definition:


{
  "networkMode": "awsvpc",
  "networkConfiguration": {
    "awsvpcConfiguration": {
      "subnets": ["subnet-12345678"],
      "assignPublicIp": "ENABLED",
      "securityGroups": ["sg-12345678"]
    }
  }
}

Create an A record that points to your ENI's private IP (more stable than public IP):


# Get ENI private IP
ENI_IP=$(aws ec2 describe-network-interfaces \
  --network-interface-ids eni-12345678 \
  --query 'NetworkInterfaces[0].PrivateIpAddress' \
  --output text)

# Update Route 53 record
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "app.yourdomain.com",
        "Type": "A",
        "TTL": 60,
        "ResourceRecords": [{"Value": "'$ENI_IP'"}]
      }
    }]
  }'

Implement a Lambda function triggered by CloudWatch Events to automate ENI reassignment during deployments:


import boto3

def lambda_handler(event, context):
    ec2 = boto3.client('ec2')
    route53 = boto3.client('route53')
    
    # Get new task's ENI
    new_eni = ec2.describe_network_interfaces(
        Filters=[{'Name': 'description', 'Values': ['*Fargate*']}]
    )['NetworkInterfaces'][0]
    
    # Update Route 53
    route53.change_resource_record_sets(
        HostedZoneId='Z1234567890',
        ChangeBatch={
            'Changes': [{
                'Action': 'UPSERT',
                'ResourceRecordSet': {
                    'Name': 'app.yourdomain.com',
                    'Type': 'A',
                    'TTL': 60,
                    'ResourceRecords': [{'Value': new_eni['PrivateIpAddress']}]
                }
            }]
        }
    )
  • Configure security groups to allow HTTP/HTTPS traffic only from CloudFront if using CDN
  • Implement WAF rules directly on the ENI level
  • Rotate ENIs periodically for security best practices

Compared to ALB solution ($20+/month):

Component Cost
ENI $0.12/day ($3.6/month)
Data Processing $0.01/GB (vs ALB's $0.008/GB)
Route 53 $0.50/month per hosted zone

When working with AWS Fargate tasks in public subnets, the ephemeral nature of public IPs creates DNS challenges. While Application/Network Load Balancers (ALB/NLB) provide stable endpoints, their cost may be prohibitive for lightweight applications. Here's a production-tested approach that survives task replacements.

Each Fargate task receives an Elastic Network Interface (ENI) with a persistent DNS name following this pattern:

ip-xxx-xxx-xxx-xxx.region.compute.internal

Though the internal DNS isn't directly accessible, we can extract the ENI's public DNS name through these steps:

1. Create a Route 53 Private Hosted Zone

aws route53 create-hosted-zone \
  --name internal.yourdomain.com \
  --vpc VPCRegion=us-east-1,VPCId=vpc-123456 \
  --caller-reference $(date +%s) \
  --hosted-zone-config Comment="ENI mapping"

2. Dynamically Update DNS Records

Use this Python Lambda function (triggered by ECS events) to maintain records:

import boto3
import os

def lambda_handler(event, context):
    ecs = boto3.client('ecs')
    route53 = boto3.client('route53')
    
    cluster = event['detail']['clusterArn'].split('/')[-1]
    task_arn = event['detail']['taskArn']
    
    task = ecs.describe_tasks(cluster=cluster, tasks=[task_arn])
    eni_id = task['tasks'][0]['attachments'][0]['details'][1]['value']
    
    ec2 = boto3.client('ec2')
    eni = ec2.describe_network_interfaces(NetworkInterfaceIds=[eni_id])
    public_dns = eni['NetworkInterfaces'][0]['Association']['PublicDnsName']
    
    route53.change_resource_record_sets(
        HostedZoneId='YOUR_ZONE_ID',
        ChangeBatch={
            'Changes': [{
                'Action': 'UPSERT',
                'ResourceRecordSet': {
                    'Name': 'app.internal.yourdomain.com',
                    'Type': 'CNAME',
                    'TTL': 60,
                    'ResourceRecords': [{'Value': public_dns}]
                }
            }]
        }
    )

For infrastructure-as-code deployments:

Resources:
  ENIMappingFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.8
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Code:
        ZipFile: |
          # Insert Lambda code from above

  EventRule:
    Type: AWS::Events::Rule
    Properties:
      EventPattern:
        source: ["aws.ecs"]
        detail-type: ["ECS Task State Change"]
      Targets:
        - Arn: !GetAtt ENIMappingFunction.Arn
  • Set TTL values ≤ 60 seconds for faster failover
  • Implement health checks on the CNAME record
  • Monitor Route 53 update limits (default 1000/second)
  • Consider weighted records for blue/green deployments

Compared to ALB ($16/month + LCU costs), this solution typically costs:

  • $0.50/month per hosted zone
  • $0.40 per million DNS queries
  • Minimal Lambda invocation costs