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.

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.
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 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 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.
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.orders sends the fields inventory expects, so a renamed field shows up as a 400 in staging.localhost ports, so the discovery path is only exercised in AWS.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.

Run in your terminal to get started locally.
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.
service block.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.
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.
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.
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.