Introduction
After running Node APIs on EC2, Lambda, and everything in between, I've settled on a pattern that balances simplicity with the operational rigor production demands. A NestJS API on ECS—when architected thoughtfully—gives you control over your infrastructure without the overhead of managing Kubernetes yourself. This reference architecture is what I've deployed at scale across e-commerce, SaaS, and ride-hailing platforms: it handles thousands of requests per second, scales automatically, and keeps secrets secure without manual intervention.
In this post, I'll walk you through the exact setup I use today, from request ingestion through Route 53 all the way to background job processing via SQS. If you're building production APIs on AWS and want something that's predictable, observable, and doesn't require constant babysitting, this is the mental model to adopt.
The Request Path: From DNS to Your API Tasks
Every request follows a deliberate journey. Understanding this path helps you debug faster and know where bottlenecks hide.
Route 53 is your entry point. I use simple weighted routing for active-active failover across regions, or geolocation routing if you need regional preference. The DNS TTL sits at 60 seconds in production—long enough to avoid query storms, short enough that failover happens quickly.
CloudFront sits next. Not every endpoint needs it; I cache aggressively only on public, read-only responses like product listings or static config. For authenticated endpoints, I disable caching or use cache headers like Cache-Control: private, max-age=0 to prevent data leaks. The real win is DDoS mitigation and reduced latency for global traffic.
Application Load Balancer (ALB) receives the request. It terminates HTTPS, strips the certificate handling burden from your tasks, and distributes traffic across ECS tasks in private subnets. I set idle connection timeout to 65 seconds and enable stickiness only when absolutely necessary (usually not).
ECS Fargate tasks run your NestJS application in private subnets—no internet gateway, no public IPs. A Network ACL and security group restrict inbound traffic to the ALB only. Each task gets 512 MB to 4 GB of memory; I typically start at 1.5 GB for moderate APIs and adjust based on heap dumps.
RDS PostgreSQL and ElastiCache live in the data layer. Both sit in private subnets, reachable only from your application security group. I enable encryption at rest and in transit; the performance hit is negligible on modern hardware. Connection pooling (usually via PgBouncer or built into your ORM) is non-negotiable—without it, you'll exhaust database connections under load.
SQS decouples background work. Long-running tasks (file uploads, report generation, notifications) are published as messages and consumed asynchronously. This keeps your synchronous request path fast and predictable.
Request Path in Action: A Concrete Example
Let's trace a POST request to create an order in an e-commerce API:
- Client calls
POST https://api.example.com/orders. - Route 53 resolves to the CloudFront distribution.
- CloudFront (cache miss) forwards to the ALB public IP.
- ALB performs HTTPS termination and selects an ECS task in a private subnet.
- NestJS receives the request, validates input, and calls the database.
- Order is inserted into RDS; a Redis cache is invalidated.
- A message is published to SQS to send a confirmation email.
- Response is returned within 100–300 ms.
- A background worker consumes the SQS message and sends the email asynchronously.
The customer never waits for the email. Your API response time stays sub-300ms. Your email service can experience temporary slowness without affecting the core order creation flow.
Secrets Management: Injecting Credentials at Container Start
Hardcoding secrets into Docker images or environment files is a quick path to a breach. AWS Secrets Manager is your answer.
I store all secrets centrally: database passwords, API keys, encryption keys, third-party credentials. The ECS task execution role has permission to fetch secrets at startup. In your task definition JSON, I reference the secret as an environment variable:
{
"containerDefinitions": [
{
"name": "api",
"image": "123456789.dkr.ecr.us-east-1.amazonaws.com/my-api:v1",
"secrets": [
{
"name": "DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/db-password:password::"
},
{
"name": "STRIPE_SECRET_KEY",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/stripe-key:key::"
}
]
}
]
}
At container start, ECS pulls these values and injects them as environment variables. Your NestJS ConfigService reads them like any other env var. If you rotate a secret in Secrets Manager, the next task deployment picks up the new value automatically.
I enable automatic rotation for database passwords—AWS handles the rotation orchestration. Set the rotation window during off-peak hours and test rotation in a staging environment first; a misconfigured rotation can lock you out.
Structured Logging to CloudWatch
Logs without structure are noise. I standardize on JSON logs because they're queryable and aggregatable.
In NestJS, I use Winston with a JSON formatter. Every log includes a correlation ID, timestamp, severity level, and context:
{
"timestamp": "2024-01-15T10:23:45.123Z",
"level": "INFO",
"message": "Order created",
"correlationId": "req-12345",
"userId": "user-789",
"orderId": "order-456",
"duration_ms": 245,
"service": "order-api"
}
ECS automatically sends container logs to CloudWatch. I set a retention policy of 30 days for production (adjustable based on compliance needs). This prevents CloudWatch from becoming a black hole of storage costs.
For troubleshooting, I use CloudWatch Insights to run ad-hoc queries:
fields @timestamp, @message, userId, duration_ms
| filter @message like /error/
| stats avg(duration_ms) by userId
This finds slow requests for a specific user in seconds. Without structured logs, you're grepping through raw text—a nightmare at scale.
Autoscaling: CPU and Request Count
Manual scaling is theater. Autoscaling is essential.
I configure target tracking on two metrics:
- CPU target: 70% — If average CPU exceeds 70%, scale up. If it drops below 50%, scale down. This catches compute-intensive workloads.
- Request count per target: 1,000 — If requests per task exceed 1,000 (adjust based on your API), add a task. This handles traffic spikes independent of CPU usage.
Here's the Auto Scaling Group configuration:
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/prod-cluster/order-api \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 3 \
--max-capacity 20
aws application-autoscaling put-scaling-policy \
--policy-name cpu-scaling \
--service-namespace ecs \
--resource-id service/prod-cluster/order-api \
--scalable-dimension ecs:service:DesiredCount \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration file://policy.json
In policy.json:
{
"TargetValue": 70.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleOutCooldown": 60,
"ScaleInCooldown": 300
}
Scale-out happens fast (60 seconds) so you respond to spikes. Scale-in is slower (300 seconds) to avoid thrashing. I also set minimum capacity to 3 tasks—never run a single task in production; if it crashes or is replaced, you're down.
Common Mistakes
- Storing secrets in environment variables without Secrets Manager: They leak in CloudWatch logs, error traces, and memory dumps. Always use Secrets Manager or AWS Systems Manager Parameter Store.
- Running ECS tasks in public subnets: This creates an unnecessary attack surface. Private subnets + ALB + security groups is the only way to go.
- Forgetting connection pooling: Each task opening a direct connection to RDS will exhaust connections under load. Use PgBouncer, HikariCP, or similar.
- Setting autoscaling minimum capacity to 1: You're one failing task away from downtime. Minimum 3 across AZs is the baseline.
- Disabling CloudWatch log retention: Your AWS bill grows indefinitely. Set retention explicitly, even if it's 90 days.
- Not testing failover: Deploy a second task, kill the first, and verify traffic switches. If you don't test it, it won't work when you need it.
Key Takeaways
- Route 53 → CloudFront → ALB → private ECS tasks is the production baseline; it scales, is resilient, and keeps security tight.
- Secrets Manager injection at task start eliminates the credential management burden and enables automatic rotation.
- JSON structured logging to CloudWatch with retention policies makes debugging fast and cost predictable.
- Target tracking autoscaling on CPU (70%) and request count per task handles both steady-state and spike traffic automatically.
- Always run at least 3 tasks across availability zones; a single task is a single point of failure.
- Decoupling synchronous work from asynchronous work via SQS keeps your API response times predictable and fast.






Comments
Loading comments…