Nov 12, 202611 min read

ECS Service Discovery: Cloud Map, Service Connect or an Internal ALB

How one Amazon ECS service finds another: Cloud Map DNS records, Service Connect short names through a managed proxy, and an internal Application Load Balancer, with Terraform for each, the failure modes that come with them, and how Encore handles service-to-service calls in code.

Illustration for ECS Service Discovery: Cloud Map, Service Connect or an Internal ALB

Two services on Amazon ECS that need to talk to each other have to agree on an address that keeps working while tasks start, stop and move between subnets. AWS offers three answers: Cloud Map DNS records that point at task IPs, Service Connect with a managed proxy in every task, and an internal Application Load Balancer in front of the service. They differ in what happens during a deployment, who handles retries and what each call costs in latency and infrastructure.

This guide compares the three options with Terraform for each, using the AWS provider 6.x and an inventory service called by an orders service, and then shows how Encore handles service-to-service calls in code.

ECS service discovery options at a glance

Cloud Map DNSService ConnectInternal ALB
Caller usesinventory.app.internalhttp://inventoryALB DNS name
Path of a requestDirect to task IPClient proxy to server proxyClient to ALB to task
Load balancingDNS answers, up to eight recordsProxy round robin with outlier detectionALB
Retries and bad targetsApplication codeProxyALB health checks
Change propagationDNS TTLRedeploying client tasksTarget registration
MetricsNone built inStandard metrics and logs per serviceALB metrics
Reachable from outside ECSAnything in the VPCOnly Service Connect clientsAnything routed to the ALB

Cloud Map DNS records

With service discovery, ECS keeps a list of running tasks in AWS Cloud Map, which maintains a DNS name in a Route 53 private hosted zone that resolves to their private IP addresses. Traffic goes straight from one container to another with no extra component in between, which the ECS guide to interconnecting services lists as the lowest-latency option. The namespace, security group and task definition below are shared by all three options:

terraform { required_providers { aws = { source = "hashicorp/aws", version = "~> 6.0" } } } provider "aws" { region = "eu-central-1" } variable "vpc_id" { type = string } variable "private_subnet_ids" { type = list(string) } variable "cluster_arn" { type = string } variable "execution_role_arn" { type = string } variable "image" { type = string } # One private DNS namespace (a Route 53 private hosted zone) for the app. resource "aws_service_discovery_private_dns_namespace" "app" { name = "app.internal" vpc = var.vpc_id } resource "aws_security_group" "services" { name = "app-services" vpc_id = var.vpc_id ingress { from_port = 8080 to_port = 8080 protocol = "tcp" self = true } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } # A named port mapping; Service Connect refers to it by name. resource "aws_ecs_task_definition" "inventory" { family = "inventory" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = 256 memory = 512 execution_role_arn = var.execution_role_arn container_definitions = jsonencode([{ name = "inventory" image = var.image essential = true portMappings = [{ name = "http", containerPort = 8080, protocol = "tcp", appProtocol = "http" }] }]) }

The inventory service registers each task as an A record with a 10 second TTL:

# Option 1: Cloud Map DNS. Tasks register as A records in app.internal. resource "aws_service_discovery_service" "inventory" { name = "inventory" dns_config { namespace_id = aws_service_discovery_private_dns_namespace.app.id routing_policy = "MULTIVALUE" dns_records { type = "A" ttl = 10 } } health_check_custom_config {} } resource "aws_ecs_service" "inventory_dns" { name = "inventory" cluster = var.cluster_arn task_definition = aws_ecs_task_definition.inventory.arn desired_count = 2 launch_type = "FARGATE" network_configuration { subnets = var.private_subnet_ids security_groups = [aws_security_group.services.id] } service_registries { registry_arn = aws_service_discovery_service.inventory.arn } } # Callers use http://inventory.app.internal:8080

The same AWS guide notes that DNS answers are cached for the TTL, so after a deployment callers can still resolve tasks that are gone, and the application has to retry and skip bad backends. A records only work with the awsvpc network mode, which Fargate always uses. With bridge or host networking, SRV records carry the port and the client has to read them. The service discovery docs add that a service using service discovery is limited to 1,000 tasks by a Route 53 quota, that Route 53 returns at most eight healthy records per query, and that ECS removes a task from DNS when its container health check fails. Route 53 and Cloud Map API usage are billed separately.

Service Connect short names

Service Connect is the option AWS recommends for ECS-to-ECS traffic. Each service in a Cloud Map namespace gets a discovery name and a client alias, ECS injects a proxy into every task, and callers connect to http://inventory on a standard port. The calling task's proxy picks a healthy target using round robin and outlier detection, as described in the Service Connect docs:

# Option 2: Service Connect. ECS adds a proxy to each task and # clients reach the service by its short name. resource "aws_ecs_service" "inventory_sc" { name = "inventory-sc" cluster = var.cluster_arn task_definition = aws_ecs_task_definition.inventory.arn desired_count = 2 launch_type = "FARGATE" network_configuration { subnets = var.private_subnet_ids security_groups = [aws_security_group.services.id] } service_connect_configuration { enabled = true namespace = aws_service_discovery_private_dns_namespace.app.arn service { port_name = "http" # must match the task definition's portMappings name discovery_name = "inventory" client_alias { dns_name = "inventory" port = 80 } timeout { per_request_timeout_seconds = 15 } } } } # Client services also need service_connect_configuration { enabled = true, # namespace = ... } and then call http://inventory

The port_name has to match the name on the task definition's port mapping, which is the usual reason a first Service Connect deployment fails. Calling services need their own service_connect_configuration with the namespace, even if they serve nothing. Endpoint changes reach a client when its tasks are replaced in a deployment, rather than when a DNS cache expires, so the rollout follows the normal deployment settings and circuit breaker.

The proxy adds CPU and memory to each task and one more hop on each side of a call, and it brings per-service request metrics without instrumentation, timeouts set in Terraform and connections across clusters and VPCs in the same Region. Encrypting traffic between Service Connect services requires an AWS Private CA, from which ECS issues and rotates certificates, per the Service Connect TLS docs.

An internal Application Load Balancer

An internal ALB is the oldest pattern and still the right one when callers are not ECS services, such as a Lambda function, an EC2 batch host or another account over a peering connection:

# Option 3: an internal ALB in front of the service. resource "aws_lb" "internal" { name = "app-internal" internal = true load_balancer_type = "application" subnets = var.private_subnet_ids security_groups = [aws_security_group.services.id] } resource "aws_lb_target_group" "inventory" { name = "inventory" port = 8080 protocol = "HTTP" target_type = "ip" vpc_id = var.vpc_id health_check { path = "/healthz" matcher = "200" } } resource "aws_lb_listener" "internal_http" { load_balancer_arn = aws_lb.internal.arn port = 8080 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.inventory.arn } } resource "aws_ecs_service" "inventory_alb" { name = "inventory-alb" cluster = var.cluster_arn task_definition = aws_ecs_task_definition.inventory.arn desired_count = 2 launch_type = "FARGATE" network_configuration { subnets = var.private_subnet_ids security_groups = [aws_security_group.services.id] } load_balancer { target_group_arn = aws_lb_target_group.inventory.arn container_name = "inventory" container_port = 8080 } depends_on = [aws_lb_listener.internal_http] } # Callers use the ALB's DNS name, usually behind a Route 53 alias.

The ALB health checks targets, drains them during deployments and can route several services under one hostname by path. It also adds a hop, an always-on load balancer per environment and a listener rule or target group for every service that joins. The security group here allows traffic on port 8080 between members of the same group. In production the ALB and the services usually get separate groups, so a task only accepts traffic from the load balancer. All three configurations pass terraform validate with Terraform 1.13 and the AWS provider 6.x.

What still takes work after the first apply

  • Every caller hardcodes an address. inventory.app.internal:8080, http://inventory or an ALB hostname goes into configuration for each consumer and has to change if the service is renamed or split.
  • Request and response types drift. Nothing checks at build time that orders sends the fields inventory expects, so a renamed field shows up as a 400 in staging.
  • Local development looks different. On a laptop the services run on localhost ports, so the discovery path is only exercised in AWS.
  • Security groups and IAM grow with every new caller, one ingress rule or Service Connect client at a time.

How does Encore handle service-to-service calls?

Encore

Encore brings infrastructure from code to TypeScript and Go backends. Services are declared in code, a call from one service to another is a typed function call, and the Encore platform deploys the services to Fargate or EKS in your own AWS account. According to the AWS infrastructure docs, the Fargate task definitions Encore creates are integrated with AWS Service Discovery, which registers services automatically for service-to-service communication, so there is no namespace, discovery service or client alias to define.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop --example=hello-world$ cd shop && mkdir -p inventory/migrations orders

The inventory service owns a Postgres database and exposes a private Reserve endpoint, which can only be called by other services in the app. The order ID doubles as an idempotency key, so a retried call returns the first reservation instead of taking stock twice:

// inventory/inventory.go package inventory import ( "context" "errors" "encore.dev/beta/errs" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("inventory", sqldb.DatabaseConfig{Migrations: "./migrations"}) type ReserveParams struct { OrderID string // used as the idempotency key SKU string Qty int } type ReserveResponse struct { ReservationID int64 } // Reserve is private: callable by other services in the app, not from the internet. // //encore:api private method=POST path=/inventory/reserve func Reserve(ctx context.Context, p *ReserveParams) (*ReserveResponse, error) { tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback() // A retried call for the same order returns the existing reservation. var id int64 err = tx.QueryRow(ctx, ` INSERT INTO reservations (order_id, sku, qty) VALUES ($1, $2, $3) ON CONFLICT (order_id, sku) DO NOTHING RETURNING id`, p.OrderID, p.SKU, p.Qty).Scan(&id) if errors.Is(err, sqldb.ErrNoRows) { err = tx.QueryRow(ctx, `SELECT id FROM reservations WHERE order_id = $1 AND sku = $2`, p.OrderID, p.SKU).Scan(&id) if err != nil { return nil, err } return &ReserveResponse{ReservationID: id}, tx.Commit() } else if err != nil { return nil, err } res, err := tx.Exec(ctx, `UPDATE stock SET available = available - $2 WHERE sku = $1 AND available >= $2`, p.SKU, p.Qty) if err != nil { return nil, err } if res.RowsAffected() == 0 { return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "not enough stock"} } return &ReserveResponse{ReservationID: id}, tx.Commit() }

The migration creates the stock table and the reservations table, with the unique constraint the endpoint relies on:

CREATE TABLE stock ( sku TEXT PRIMARY KEY, available INT NOT NULL CHECK (available >= 0) ); CREATE TABLE reservations ( id BIGSERIAL PRIMARY KEY, order_id TEXT NOT NULL, sku TEXT NOT NULL REFERENCES stock (sku), qty INT NOT NULL, UNIQUE (order_id, sku) );

The orders service imports inventory as a Go package and calls Reserve like a function:

// orders/orders.go package orders import ( "context" "encore.app/inventory" // the other service, imported like a package "encore.dev/beta/errs" ) type PlaceParams struct { OrderID string SKU string Qty int } type PlaceResponse struct { ReservationID int64 } //encore:api public method=POST path=/orders func Place(ctx context.Context, p *PlaceParams) (*PlaceResponse, error) { // No hostname, port or client setup: this compiles to an API call // to the inventory service wherever it runs. r, err := inventory.Reserve(ctx, &inventory.ReserveParams{ OrderID: p.OrderID, SKU: p.SKU, Qty: p.Qty, }) if err != nil { return nil, errs.Wrap(err, "reserve stock") } return &PlaceResponse{ReservationID: r.ReservationID}, nil }

Per the API calls docs, Encore uses static analysis to compile this into a real API call between the services, and the parameters are checked at compile time. A renamed field in ReserveParams breaks the build of orders instead of producing a 400 in staging. The security docs state that all communication uses mutual TLSv1.3, with no certificate authority to set up for traffic between services.

Under encore run both services and the database run locally and the call takes the same code path, and distributed tracing shows the Place request and the nested Reserve call as one trace. The Flow diagram draws orders depending on inventory from the same code. A coding agent that splits a third service out of inventory moves the endpoints into a new package and fixes the imports the compiler flags, with no DNS name, client alias or security group rule to add.

Best fit: TypeScript and Go teams running several services on AWS who want typed, compile-checked calls between them.

Good to know

  • ECS does not support registering services into shared Cloud Map namespaces, which matters for multi-account setups built on AWS Resource Access Manager.
  • A service can use both a load balancer and service discovery at once, which is a common bridge while moving callers from an ALB to direct calls.
  • Service Connect also works for clients on ECS that only call others and expose nothing, configured with the namespace and no service block.

Frequently asked questions

What is the difference between ECS service discovery and Service Connect?

Service discovery registers each task in AWS Cloud Map, and callers resolve a DNS name such as inventory.app.internal straight to task IP addresses, so retries and stale DNS entries are handled by the caller. Service Connect also uses a Cloud Map namespace, but ECS adds a proxy to every task that load balances, applies outlier detection and publishes standard metrics, and callers use a short name such as http://inventory.

Should I use an internal ALB for service-to-service traffic on ECS?

An internal ALB fits when callers sit outside the ECS cluster, when you need path-based routing to several services behind one name, or when you want health-checked targets without changing client code. It adds a network hop and an always-on load balancer per environment, and AWS recommends Service Connect for traffic between ECS services.

Why does my ECS service still call old tasks after a deployment with Cloud Map?

Callers cache DNS records for the record's TTL, so for a while after a deployment they can resolve IP addresses of tasks that have stopped. Keep the TTL short, for example 10 seconds, retry on connection errors, and avoid clients that resolve a hostname once at startup and keep the result.

How do Encore services call each other on AWS?

A Go service imports the other service's package, for example import "encore.app/inventory", and calls its endpoint as a function, such as inventory.Reserve(ctx, params). Encore compiles the call into an API call, so the calling code has no hostname, port or HTTP client, and endpoints marked //encore:api private can only be called from other services in the app.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders