Run ECS Fargate tasks without a public IP using a NAT Gateway
Move an ECS Fargate service into private subnets, turn off auto-assign public IP, and route outbound traffic through a NAT Gateway so every call leaves from one fixed Elastic IP.

WhoAmI => notes.sohag.pro/author
When you create an ECS service with the default networking settings, every task gets its own public IP. Three tasks, three public IPs. Redeploy and you get three new ones. Your security groups are the only thing between those containers and the rest of the internet.
Most of the time you don't want that. The service is already behind a load balancer, so nobody needs to reach the task directly. And if a bank or a payment partner ever asks you for a fixed IP to whitelist, you have nothing stable to give them.
The home wifi version of this
Your laptop does not have a public IP. Neither does your phone or your TV. They all sit on private addresses like 192.168.0.5, and the router in your living room is the only device with an address the internet can see.
When your laptop asks for a webpage, the router swaps the private address for its own on the way out, and remembers who asked so it can pass the reply back. Traffic you start works fine. Traffic someone else starts never reaches your laptop, because there is no address to send it to.
A NAT Gateway is that router, for a subnet in AWS. Your tasks keep their private addresses and reach the internet through one shared public IP.
Before and after
Right now:
Internet
|
+---------------+---------------+
| | |
54.x.x.x 3.x.x.x 18.x.x.x <-one public IP per task,
| | | new set on every deploy
+--------+ +--------+ +--------+
| Task A | | Task B | | Task C |
+--------+ +--------+ +--------+
\_____________ public subnet __________/
After the change:
Internet
|
13.250.7.42 <- one Elastic IP, stays put
|
+---------------+
| NAT Gateway | public subnet
+---------------+
^
| 0.0.0.0/0 route
+---------------+---------------+
| | |
+--------+ +--------+ +--------+
| Task A | | Task B | | Task C | private subnet
+--------+ +--------+ +--------+
10.0.2.4 10.0.2.9 10.0.2.17 no public IP
Incoming traffic still works, because the load balancer never needed the task to be public in the first place:
What has to exist first
A NAT Gateway sitting in a public subnet, with an Elastic IP attached. It has to be in a public subnet, because the NAT itself needs a route to the internet gateway. Putting it in a private subnet is the single most common way to build a NAT that goes nowhere.
Private subnets whose route table sends 0.0.0.0/0 to that NAT Gateway. Open the route table for each subnet you plan to use and confirm the default route is there. A subnet with no default route at all is the second most common failure, and it looks like this: the task starts, tries to pull its image from ECR, times out after a few minutes, and ECS kills it and tries again. The stopped task shows a ResourceInitializationError about not being able to reach the registry. Nothing in that message says "your route table is empty", so check it before you blame the image.
The change itself
Amazon Elastic Container Service, then Clusters, then secret-project-cluster, then Services, then secret-api-service, then Update service.
Scroll down to the networking section. Remove the public subnets and select your private subnets instead. Then find the checkbox for auto-assigning a public IP to the task's elastic network interface and uncheck it. Click Update.
ECS starts a fresh deployment. New tasks come up in the private subnets with no public IP, the load balancer registers them, and the old tasks drain and stop. From that point on, anything your API calls out to (a partner sandbox, a webhook endpoint, an SMS gateway) sees the NAT Gateway's Elastic IP as the source.
Checking that it worked
Open the running task in the console and look at its networking details. The private IP will be there. The public IP field should be empty.
If ECS Exec is enabled on the service, get a shell into the container and ask the internet what your address is:
aws ecs execute-command \
--cluster secret-project-cluster \
--task <task-id> \
--container <container-name> \
--interactive \
--command "/bin/sh"
# then, inside the container
curl ifconfig.me
That should print your NAT Gateway's Elastic IP, not the task's own address.
Things that bite people
NAT Gateways bill by the hour and by the gigabyte processed. Every image pull, every log line to CloudWatch, every call to Secrets Manager now goes through it. If your images are large and you deploy often, that adds up. VPC endpoints cut most of it out: the S3 gateway endpoint costs nothing and handles the actual image layers, while interface endpoints for ECR and CloudWatch Logs have an hourly charge that is usually still cheaper than pushing the same traffic through NAT.
A NAT Gateway lives in one availability zone. If your private subnet in ap-southeast-1b routes to a NAT in ap-southeast-1a, it works, but you pay for cross-AZ data and you lose outbound access for that subnet whenever 1a has a bad day. One NAT per AZ is the safer shape, and also the more expensive one, so pick based on what the service actually does.
Leave the load balancer alone. Its subnets stay public. The only thing moving into private subnets is the service's task networking.
NAT stops strangers from starting a conversation with your task. It does not stop your task from talking to anything it wants. If a container gets compromised, it can still reach the whole internet outbound. Egress rules on the security group are a separate job.
Once the deployment settles, copy the NAT's Elastic IP somewhere your team will find it later. That address is now the one every external party sees, and it is the one you hand over when someone asks you to be whitelisted.



