Deploy a Stripe Payment API to AWS
Deploy a production Stripe payment API on AWS with HTTPS, secrets management, and IaC.
Introduction
30 Second Summary
Your payment API works perfectly on your laptop. But the moment real users need to reach it over the internet, you need encrypted connections, secret key management, and infrastructure that can be reproduced or torn down on command.
In this project, you will deploy your Stripe payment API to AWS using Docker, ECS Fargate, and Terraform. You will set up HTTPS with a custom domain, inject secrets securely at runtime, and codify the entire stack so it can be created or destroyed with a single command.
What You'll Build
You hand someone your custom HTTPS URL, they hit your API, and a Stripe test charge flows through with proper TLS encryption, health monitoring, and safe database migrations.
By the end of this project, you'll have:
- A live HTTPS payment API on a custom domain that processes Stripe test charges end-to-end, with HTTP requests automatically redirecting to HTTPS.
- Secrets injected at runtime from AWS Secrets Manager so your Stripe keys and database credentials never appear in plaintext in code or config files.
- A health monitoring setup with separate /health and /ready endpoints that prove your container is alive and your database is connected before traffic flows.
- All infrastructure defined in Terraform for one-command reproducibility, with RDS PostgreSQL isolated in private subnets unreachable from the public internet.
- Secret Mission: Add a WAF rate-limiting rule to protect your payment endpoints against card-testing attacks.
Are there any prerequisites?
This project assumes you have a working Node.js/Express payment API with PostgreSQL and Stripe Test Mode from earlier projects in this series. You also need Docker, the AWS CLI (configured with credentials), and an AWS account.
Before We Start
Before we provision any infrastructure, let's get clear on what you're deploying and why a payment system demands production-grade safeguards. Your API works locally, but handling real payment data over the internet introduces threats that localhost never faces.
Set Up Tooling and Container
Your payment API works locally, but deploying it to production requires two things you don't have yet: an infrastructure provisioning tool and a containerized build.
In this step, you'll install Terraform, add the health check endpoints your load balancer needs to monitor the container, and build a production-ready Docker image.
In this step, get ready to:
- Install Terraform CLI and initialize the AWS provider configuration.
- Add health and readiness endpoints to your Express app.
- Build a production Docker image with a multi-stage Dockerfile.
Install Terraform and create the provider configuration
Terraform is the tool you'll use to define all your AWS infrastructure as code. Check if it's already on your machine.
- Check whether Terraform is installed by running this command:
terraform --version
✔️ I see a version number
Terraform is installed and ready. Continue to the next instruction below.
ⓧ Command not found
Install Terraform using your platform's package manager.
macOS
- Add the HashiCorp tap and install Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
Windows
- Install Terraform with Chocolatey by running this command in an Administrator PowerShell:
choco install terraform
- After installation completes, close and reopen your terminal.
- Verify the install by running:
terraform --version
You should see a version number printed (e.g. Terraform v1.15.8).
Now create the directory where all your Terraform configuration files will live.
- Create the infra/ directory and the provider file by running:
mkdir infra
touch infra/main.tf
- Open infra/main.tf in your editor and paste the following configuration:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
What does this configuration do?
The required_providers block tells Terraform to download the AWS provider from the HashiCorp registry.
The version = "~> 6.0" constraint allows any 6.x release while preventing accidental jumps to a future major version that might introduce breaking changes.
The provider block configures where your resources will be created. All infrastructure in this project deploys to us-east-1.
- Save infra/main.tf.
- Initialize Terraform to download the AWS provider by running:
cd infra
terraform init
You should see output ending with "Terraform has been successfully initialized" and a reminder about terraform plan.
- Move back to your project root by running:
cd ..
Seeing a provider download error?
- Check that you have an active internet connection. Terraform downloads the provider binary from the HashiCorp registry.
- Verify your infra/main.tf matches the code above exactly. A missing closing brace or misspelled hashicorp/aws will cause a failure.
Still stuck? Help me troubleshoot my terraform init error
Add health and readiness endpoints
An Application Load Balancer needs a way to know your container is alive. You'll add two endpoints that serve different purposes.
- Open server.js in your editor.
- Add the following two route handlers after your existing routes but before any error-handling middleware:
// /health endpoint - ALB health check (no DB)
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// /ready endpoint - full-stack readiness check
app.get('/ready', async (req, res) => {
try {
await pool.query('SELECT 1');
res.status(200).json({ status: 'ready' });
} catch (err) {
res.status(503).json({ status: 'unavailable', error: err.message });
}
});
Why two separate endpoints?
The ALB health check hits /health every 10 seconds. It returns 200 instantly with no database round-trip, so a slow query never causes the ALB to mistakenly kill your container.
The /ready endpoint runs SELECT 1 against PostgreSQL. Deployment tooling checks this to confirm the database is reachable and migrations have completed before routing live traffic.
- Save server.js.
- Start your server locally to confirm the health endpoint works:
node server.js
- In a separate terminal window, test the health endpoint by running:
curl http://localhost:3000/health
You should see {"status":"ok"} printed in your terminal.
- Stop the server with Ctrl+C in the terminal where it's running.
Not seeing the JSON response?
- Make sure you placed the routes before any catch-all or error-handling middleware in server.js. Express evaluates routes top-to-bottom.
- Check that your server is running on port 3000. If your app uses a different port, adjust the curl command accordingly.
Still stuck? Help me debug why my /health endpoint isn't responding
Write the Dockerfile and entrypoint script
Your API needs a container image that runs migrations before accepting traffic. You'll create a two-part setup: an entrypoint script that handles startup sequencing, and a multi-stage Dockerfile that keeps the final image small.
- Create the entrypoint script in your project root by running:
touch docker-entrypoint.sh
- Open docker-entrypoint.sh and paste the following:
#!/bin/sh
set -e
echo "Running database migrations..."
node migrate.js
echo "Starting server..."
exec node server.js
Why use exec before node server.js?
The exec command replaces the shell process with the Node process. This means Node becomes PID 1 inside the container and receives SIGTERM directly when ECS stops the task.
Without exec, the shell traps the signal and Node never gets a chance to shut down gracefully. This can leave database connections dangling.
- Save docker-entrypoint.sh.
- Create the Dockerfile in your project root by running:
touch Dockerfile
- Open Dockerfile and paste the following multi-stage build:
# Stage 1: builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Stage 2: production
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app .
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/docker-entrypoint.sh"]
What does a multi-stage build do?
Stage 1 uses node:22-alpine (a lightweight image) to install all dependencies including devDependencies. This stage is a throwaway build environment.
Stage 2 starts from node:22-slim and copies only the production node_modules and application source from Stage 1. The final image contains nothing extra: no build tools, no devDependencies, no package manager cache.
- Save Dockerfile.
- Confirm both new files exist by running:
ls docker-entrypoint.sh Dockerfile
You should see both filenames listed in the output.
Before you build, what size do you think the final image will be? Multi-stage builds with node:22-slim typically produce images well under 200MB.
- Build the Docker image by running:
docker build -t payment-api .
- Verify the image size by running:
docker images payment-api
You should see a single row showing the payment-api image with a size under 200MB.
Build failing with a COPY error?
- Confirm that docker-entrypoint.sh is in the same directory as the Dockerfile (your project root). Docker can only copy files from the build context.
- If you see a permission error on the entrypoint, ensure RUN chmod +x /docker-entrypoint.sh is present in your Dockerfile.
- Make sure package.json exists in the root. The COPY package*.json ./ line expects it there.
Still stuck? Help me debug my Docker build failure
✔️ Awesome, I've got everything!
Great. Double check you've saved all three new files: infra/main.tf, docker-entrypoint.sh, and Dockerfile.
ⓧ I'd like to double check the full code
Scroll up and confirm each file matches the code blocks you pasted. Here are the files you should have:
- infra/main.tf with the terraform block and provider "aws" block.
- docker-entrypoint.sh with the shebang, set -e, migration run, and exec node server.js.
- Dockerfile with Stage 1 (node:22-alpine builder) and Stage 2 (node:22-slim production).
- The /health and /ready routes added to server.js.
Your tooling is set up and your API is containerized. Next up, you'll use Terraform to provision the full AWS infrastructure: VPC, database, and secrets.
Deploy Infrastructure Foundation with Terraform
Your Docker image is built and your Terraform provider is configured. Now you need the actual cloud infrastructure for your API to run on.
A payment API needs more than a server. It needs an isolated network, a private database, and a secure place to store credentials like Stripe keys. Terraform lets you define all of this in code, so you can create it with one command and destroy it with another.
In this step, get ready to:
- Write Terraform configuration for a VPC with public and private subnets, security groups, and an RDS PostgreSQL database.
- Create Secrets Manager entries for database credentials and Stripe keys.
- Register a domain, configure Route 53 DNS, and apply your infrastructure.
Write the networking and database configuration
Your API container will eventually need a network to live in, a database to connect to, and security rules that prevent the public internet from reaching the database directly. You will define all of this in a single main.tf file.
- Switch to your infra/ directory in the terminal by running:
cd infra
First, you need to add the random provider to your Terraform configuration. This provider generates a secure password for your database without you ever seeing or typing it.
- Replace the entire contents of infra/main.tf with the following provider block and password resource:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Cost checkpoint: RDS db.t4g.micro ~$0.016/hr ($0.38/day). No NAT Gateway saves ~$1.08/day.
resource "random_password" "db_password" {
length = 32
special = true
}
What does this code do?
The random provider is a Terraform plugin that generates random values. The random_password resource creates a 32-character password with special characters that Terraform manages. You will reference this password later when creating the RDS database.
The cost comment reminds you what you will be paying. No NAT Gateway in this architecture saves about $32/month.
- Download the new provider by running:
terraform init
You should see Terraform downloading the hashicorp/aws and hashicorp/random providers, ending with the message Terraform has been successfully initialized!
Seeing a provider error?
If you see an error about locked provider versions, run terraform init -upgrade to allow Terraform to re-evaluate the version constraints. Make sure your required_providers block matches the code above exactly.
help me fix a Terraform init error with provider versions
Now add the VPC and Internet Gateway. The VPC is the isolated network where all your resources live. The Internet Gateway lets resources in public subnets reach the internet.
- Add the following code to the bottom of infra/main.tf:
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "payment-api-vpc" }
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "payment-api-igw" }
}
What does this code do?
The VPC uses CIDR 10.0.0.0/16 which gives you 65,536 IP addresses to work with. DNS hostnames and support are enabled so your RDS instance gets a resolvable hostname that your container can connect to.
The Internet Gateway attaches to the VPC and provides a route for public subnets to reach the outside world.
Next, define four subnets. Two public subnets will hold the load balancer and Fargate tasks. Two private subnets will hold the database where no internet traffic can reach it.
- Add the subnet definitions below the Internet Gateway in infra/main.tf:
# Public Subnets
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = { Name = "payment-api-public-1" }
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
tags = { Name = "payment-api-public-2" }
}
# Private Subnets
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "us-east-1a"
tags = { Name = "payment-api-private-1" }
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = "us-east-1b"
tags = { Name = "payment-api-private-2" }
}
Why two of each subnet?
Each pair is spread across two Availability Zones (us-east-1a and us-east-1b). This is required by the Application Load Balancer. If one data center has an outage, the other keeps serving traffic.
Public subnets set map_public_ip_on_launch = true so Fargate tasks get a public IP to reach ECR and Secrets Manager directly. Private subnets have no internet route at all.
- Validate your configuration so far by running:
terraform validate
You should see: Success! The configuration is valid.
Seeing a syntax error?
Check that every opening brace { has a matching closing brace }. Make sure string values are wrapped in double quotes. The most common issue is a missing quote or a typo in a resource reference like aws_vpc.main.id.
help me fix a Terraform validate syntax error
Now add the route table that connects the public subnets to the Internet Gateway. Without this, resources in public subnets cannot reach the internet even though they have public IPs.
- Add the route table and its associations below the subnets in infra/main.tf:
# Route Table for public subnets
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "payment-api-public-rt" }
}
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
What does this code do?
The route 0.0.0.0/0 means "all traffic not destined for this VPC goes to the Internet Gateway." Each association links a public subnet to this route table so traffic can flow outbound.
Private subnets intentionally have no route table association pointing to the IGW. That is what makes them private.
Next, define the security groups. The RDS security group only allows traffic from the ECS task security group on port 5432. This means the database is reachable exclusively from your container.
- Add the security groups below the route table associations in infra/main.tf:
# RDS Security Group
resource "aws_security_group" "rds" {
name = "payment-api-rds-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs_tasks.id]
}
tags = { Name = "payment-api-rds-sg" }
}
# ECS Task Security Group (referenced by RDS SG)
resource "aws_security_group" "ecs_tasks" {
name = "payment-api-ecs-tasks-sg"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "payment-api-ecs-tasks-sg" }
}
What does this code do?
The RDS security group has a single ingress rule: allow TCP on port 5432 (PostgreSQL) only from resources that belong to the ecs_tasks security group. No other traffic gets in.
The ECS tasks security group allows all outbound traffic (egress to 0.0.0.0/0) so your container can reach ECR, Secrets Manager, and the internet. Inbound rules for the tasks will be added in the next step when the load balancer is configured.
- Validate again by running:
terraform validate
You should see Success! The configuration is valid. again.
Finally for networking, define the database itself. The RDS instance goes into the private subnets behind the RDS security group.
- Add the DB subnet group and RDS instance below the security groups in infra/main.tf:
# DB Subnet Group
resource "aws_db_subnet_group" "main" {
name = "payment-api-db-subnet-group"
subnet_ids = [aws_subnet.private_1.id, aws_subnet.private_2.id]
tags = { Name = "payment-api-db-subnet-group" }
}
# RDS Instance
resource "aws_db_instance" "main" {
identifier = "payment-api-db"
engine = "postgres"
engine_version = "16"
instance_class = "db.t4g.micro"
allocated_storage = 20
storage_type = "gp3"
db_name = "payments"
username = "paymentapi"
password = random_password.db_password.result
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = false
skip_final_snapshot = true
tags = { Name = "payment-api-db" }
}
What does this code do?
The DB subnet group tells RDS which subnets to use. Both are private, so the database has no internet-facing route.
The RDS instance uses db.t4g.micro (the smallest instance class, about $0.38/day). The password references the random_password resource you created earlier. You never type or see this password. skip_final_snapshot = true means Terraform can destroy the database cleanly without requiring a final backup.
💡 Why public subnets for Fargate instead of private?
This project places ECS Fargate tasks in public subnets with a public IP. This eliminates the NAT Gateway, which costs about $0.045/hr ($32/month). Tasks can reach ECR, Secrets Manager, and CloudWatch directly via the Internet Gateway.
Security is maintained by restricting the task security group to allow inbound traffic ONLY from the ALB security group. The database remains in private subnets with no internet route.
In a compliance-sensitive production environment, you would use private subnets with either a NAT Gateway or VPC Interface Endpoints. For a portfolio deployment, public subnets with security group restrictions are an accepted tradeoff.
Add Secrets Manager resources
Your container needs database credentials and Stripe keys at runtime. AWS Secrets Manager stores these encrypted values and injects them into your ECS container at boot. No plaintext secrets in your code or environment.
- Add the Secrets Manager configuration below the RDS instance in infra/main.tf:
# Secrets Manager
resource "aws_secretsmanager_secret" "database" {
name = "payment-api/database"
}
resource "aws_secretsmanager_secret_version" "database" {
secret_id = aws_secretsmanager_secret.database.id
secret_string = jsonencode({
host = aws_db_instance.main.address
port = "5432"
username = "paymentapi"
password = random_password.db_password.result
dbname = "payments"
})
}
resource "aws_secretsmanager_secret" "stripe" {
name = "payment-api/stripe"
}
resource "aws_secretsmanager_secret_version" "stripe" {
secret_id = aws_secretsmanager_secret.stripe.id
secret_string = jsonencode({
secret_key = "sk_test_PLACEHOLDER"
webhook_secret = "whsec_PLACEHOLDER"
})
}
What does this code do?
Two secrets are created. The payment-api/database secret dynamically references the RDS endpoint and the generated password, so it is always correct. The payment-api/stripe secret uses placeholder values that you will update manually after apply with your real Stripe test-mode keys.
Secrets Manager costs $0.40 per secret per month. For two secrets that is $0.80/month total.
- Run terraform plan to see what Terraform will create:
terraform plan
Terraform shows a plan listing all the resources it intends to create. Look for the line at the bottom that says something like Plan: 14 to add, 0 to change, 0 to destroy. This confirms Terraform understands your entire configuration.
Plan shows errors?
If you see an error about invalid references, make sure every resource name matches exactly (e.g. aws_security_group.ecs_tasks.id not aws_security_group.ecs_task.id). Terraform is case-sensitive and typo-sensitive.
help me fix a Terraform plan error
Register a domain and apply your infrastructure
Your API will need HTTPS, and HTTPS requires a TLS certificate. ACM (AWS Certificate Manager) can only issue certificates for domains you own. The default ALB DNS name (*.elb.amazonaws.com) is owned by AWS, so ACM cannot validate your ownership of it. You need your own domain.
✔️ I can register through Route 53
- Check domain pricing by running:
aws route53domains list-prices --tld .click --region us-east-1
What pricing should I expect?
Cheap TLDs like .click, .link, or .site typically cost $3-7/year. You can try different TLDs by changing .click in the command. Pick whichever is cheapest. Route 53 domain registration cannot use AWS Promotional Credits.
- Open the AWS Management Console in your browser.
- Navigate to Route 53 by searching for it in the top search bar.
- Click Register domains in the left sidebar.
- Search for a domain name you want (e.g. yourname-payments.click).
- Select your domain and complete the registration process.
- Wait for the registration to show as Successful in the Requests section. This usually takes 1-5 minutes.
Route 53 automatically creates a hosted zone for your domain when you register it. No extra steps needed.
ⓧ My account doesn't support Route 53 registration
Some AWS accounts (including Free Tier accounts) cannot register domains through Route 53. You can register your domain through an external registrar and delegate DNS to Route 53 instead.
- Register a domain through an external registrar like Namecheap, Porkbun, or Cloudflare Registrar. A .click or .link domain typically costs $2-5/year.
- Back in your terminal, create a Route 53 hosted zone for your new domain by running:
aws route53 create-hosted-zone \
--name [[DOMAIN="yourdomain.click"]] \
--caller-reference "payment-api-$(date +%s)"
The output includes a NameServers array with four values (e.g. ns-123.awsdns-45.com). Copy all four.
- In your external registrar's dashboard, find the Nameservers or DNS settings for your domain.
- Replace the default nameservers with the four Route 53 NS values you copied.
- Save the nameserver changes.
How long does nameserver propagation take?
Nameserver changes can take anywhere from a few minutes to 48 hours to propagate globally. In practice, most registrars propagate within 15-30 minutes. You can continue with the next steps while this propagates.
- Verify Route 53 is answering for your domain by running:
dig [[DOMAIN="yourdomain.click"]] NS +short
Once propagation completes, you should see the four Route 53 nameservers in the output. If you still see the registrar's defaults, wait a few more minutes and retry.
Now add a data source so Terraform can reference the Route 53 hosted zone configured for your domain. This works whether the domain was registered through Route 53 or purchased from an external registrar and delegated to Route 53 using its nameservers.
- Add the Route 53 data source at the bottom of infra/main.tf:
# Route 53 hosted zone data source
data "aws_route53_zone" "main" {
name = [[DOMAIN="yourdomain.click"]]
private_zone = false
}
What does this code do?
A data source reads existing resources rather than creating them. This looks up the hosted zone in Route 53 that corresponds to your domain. Later steps will use it to create DNS records for your certificate and load balancer.
Before you apply, take a moment to predict the outcome. Terraform will create real AWS resources that cost money while running.
- Apply your infrastructure by running:
terraform apply
- Terraform shows the full plan and asks for confirmation. Type yes and press Enter.
This takes a few minutes
RDS database creation typically takes 5-8 minutes. Terraform waits for the database to reach Available status before completing. Do not interrupt the process.
When the apply finishes, you should see a message like Apply complete! Resources: 14 added, 0 changed, 0 destroyed.
- Verify in the AWS Console. Navigate to VPC and confirm payment-api-vpc exists with 2 public and 2 private subnets.
- Navigate to RDS and confirm the payment-api-db instance shows Available status.
- Navigate to Secrets Manager and confirm 2 secrets (payment-api/database and payment-api/stripe) appear.
Apply failed?
If you get an error about the Route 53 zone not found, make sure your domain registration completed successfully and the name in your data source matches your registered domain exactly (including the TLD).
If RDS creation fails with a subnet error, verify that both private subnets are in different availability zones (us-east-1a and us-east-1b).
help me fix a terraform apply failure
✔️ Awesome, I've got everything!
Great. Double check that you have saved infra/main.tf and that your terraform apply completed successfully.
ⓧ I'd like to double check the full code
Your infra/main.tf is over 30 lines, so here is a checklist of the blocks you built, in order. Scroll up and confirm each one is present:
- Terraform block with aws and random providers, plus the provider "aws" block.
- Cost checkpoint comment and random_password "db_password" resource.
- VPC (aws_vpc "main") and Internet Gateway (aws_internet_gateway "main").
- Four subnets: public_1, public_2, private_1, private_2.
- Route table (aws_route_table "public") and two associations.
- Security groups: aws_security_group "rds" and aws_security_group "ecs_tasks".
- DB subnet group (aws_db_subnet_group "main") and RDS instance (aws_db_instance "main").
- Two Secrets Manager secrets (payment-api/database and payment-api/stripe) with their versions.
- Route 53 data source (data "aws_route53_zone" "main") referencing your domain.
Your cloud foundation is live. You have a VPC, subnets, a running PostgreSQL database, and secrets ready to be injected into your container. Next up, you will build and push your Docker image, then deploy it onto this infrastructure with ECS Fargate.
Build, Push, and Deploy the Container
Your VPC, database, and secrets are all provisioned, but nothing is actually running yet. Right now there is no container, no load balancer, and no service pulling your Docker image from the cloud.
This step wires everything together. You will create an ECR repository to store your image, an Application Load Balancer to route traffic, and an ECS Fargate service that runs your container with secrets injected at boot. By the end, you will curl your ALB and get a live health response from your payment API.
In this step, get ready to:
- Define the ECR repository, IAM execution role, and ECS task definition in Terraform.
- Add an ALB with health checks and an ECS service to run your container.
- Push your Docker image and verify the deployed API responds on /health.
Add the container registry and task definition
Before ECS can run your container, it needs three things: a place to pull the image from (ECR), permission to pull it and read secrets (IAM role), and a definition of what to run (task definition).
- Open infra/main.tf and add the following resources directly below the data "aws_route53_zone" "main" block.
- Add the caller identity data source, ECR repository, CloudWatch log group, and IAM execution role by pasting this code:
data "aws_caller_identity" "current" {}
# ECR
resource "aws_ecr_repository" "payment_api" {
name = "payment-api"
}
# CloudWatch
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/payment-api"
retention_in_days = 7
}
# IAM Execution Role
resource "aws_iam_role" "ecs_execution" {
name = "payment-api-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
What does this code do?
The ECR repository is a private Docker registry where your pushed image lives. The CloudWatch log group captures container stdout/stderr with a 7-day retention to keep costs low.
The IAM role uses an assume_role_policy that lets the ECS service (ecs-tasks.amazonaws.com) assume it. The actual permissions come in the next chunk.
- Directly below the IAM role, add the execution policy that grants pull, secrets, and logging permissions:
resource "aws_iam_role_policy" "ecs_execution" {
name = "payment-api-ecs-execution-policy"
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:GetAuthorizationToken"
]
Resource = "*"
},
{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = [
aws_secretsmanager_secret.database.arn,
aws_secretsmanager_secret.stripe.arn
]
},
{
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "*"
}
]
})
}
Why scope Secrets Manager access?
The secretsmanager:GetSecretValue permission is scoped to only the two secret ARNs your API needs. If the container were compromised, an attacker could not read other secrets in your account. ECR and CloudWatch use Resource = "*" because the authorization token call is account-wide, not resource-specific.
- Confirm your syntax is correct so far by running:
terraform validate
You should see Success! The configuration is valid. in your terminal.
Seeing a validation error?
- Check for missing commas between JSON array items in the policy statements.
- Ensure the closing braces and brackets in jsonencode are balanced.
- Make sure you placed the new blocks after the Route 53 data source, not inside another resource.
Help me fix a Terraform validation error in my IAM policy
- Now add the ECS task definition below the IAM policy. This tells Fargate exactly what image to run, how much CPU/memory to allocate, and which secrets to inject:
# ECS Task Definition
resource "aws_ecs_task_definition" "payment_api" {
family = "payment-api"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([{
name = "payment-api"
image = "${aws_ecr_repository.payment_api.repository_url}:latest"
portMappings = [{ containerPort = 3000, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/payment-api"
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ecs"
}
}
secrets = [
{ name = "DATABASE_HOST", valueFrom = "${aws_secretsmanager_secret.database.arn}:host::" },
{ name = "DATABASE_PASSWORD", valueFrom = "${aws_secretsmanager_secret.database.arn}:password::" },
{ name = "DATABASE_USER", valueFrom = "${aws_secretsmanager_secret.database.arn}:username::" },
{ name = "DATABASE_NAME", valueFrom = "${aws_secretsmanager_secret.database.arn}:dbname::" },
{ name = "STRIPE_SECRET_KEY", valueFrom = "${aws_secretsmanager_secret.stripe.arn}:secret_key::" },
{ name = "STRIPE_WEBHOOK_SECRET", valueFrom = "${aws_secretsmanager_secret.stripe.arn}:webhook_secret::" }
]
}])
}
How does secrets injection work?
The secrets block maps environment variable names to Secrets Manager ARNs with JSON key references. The format is arn:...:secret-name:json-key:: where the trailing colons mean "latest version, no specific version-id".
At container boot, ECS resolves each secret and injects the value as a plain environment variable. Your application code reads process.env.DATABASE_HOST exactly as it would locally. No code changes needed.
- Save infra/main.tf.
- Apply the infrastructure so far to create the ECR repository and task definition by running:
terraform apply -auto-approve
Terraform creates 5 new resources (ECR repo, log group, IAM role, policy, task definition). You should see Apply complete! Resources: 5 added in the output.
Apply failed with a permissions error?
- Confirm your AWS CLI credentials are configured for the correct account and region (us-east-1).
- If you see "Invalid principal", check that ecs-tasks.amazonaws.com is spelled exactly right in the assume role policy.
Help me debug a failed terraform apply for my ECS execution role
Add the load balancer and ECS service
The task definition describes what to run, but you still need something to route internet traffic to the container. An ALB accepts HTTP requests on port 80, health-checks the container on /health, and forwards traffic to healthy targets.
- First, you need an ALB security group that accepts HTTP and HTTPS from the internet. Add this below your task definition in infra/main.tf:
# ALB Security Group
resource "aws_security_group" "alb" {
name = "payment-api-alb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "payment-api-alb-sg" }
}
Why allow both 80 and 443?
Port 80 handles the initial HTTP request before redirecting to HTTPS (you will add that redirect in the next step). Port 443 is for the HTTPS listener you will configure with your TLS certificate later. Opening both now means you will not need to touch this security group again.
- Now update the existing aws_security_group.ecs_tasks resource to restrict inbound traffic to only the ALB. Find this block in your file:
resource "aws_security_group" "ecs_tasks" {
name = "payment-api-ecs-tasks-sg"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "payment-api-ecs-tasks-sg" }
}
- Replace the entire aws_security_group.ecs_tasks block with this updated version that adds an ingress rule from the ALB:
resource "aws_security_group" "ecs_tasks" {
name = "payment-api-ecs-tasks-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3000
to_port = 3000
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "payment-api-ecs-tasks-sg" }
}
Why restrict to ALB only?
The ingress rule allows port 3000 only from the ALB security group. Even though the Fargate tasks sit in public subnets with a public IP, no one on the internet can reach them directly. All traffic must pass through the ALB first.
- Validate your changes so far by running:
terraform validate
You should see Success! The configuration is valid. again.
- Next, add the ALB itself and its target group. The target group tells the ALB where to forward traffic and how to health-check the containers:
# ALB
resource "aws_lb" "main" {
name = "payment-api-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
tags = { Name = "payment-api-alb" }
}
# Target Group
resource "aws_lb_target_group" "payment_api" {
name = "payment-api-tg"
port = 3000
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/health"
interval = 10
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
matcher = "200"
}
}
Why target type ip?
Fargate uses awsvpc network mode, which gives each task its own private IP. The ALB target group must use target_type = "ip" (not instance) to route directly to those task IPs.
The health check hits /health every 10 seconds. Two consecutive 200 responses mark the target healthy. Three failures mark it unhealthy and the ALB stops sending traffic to that task.
- Add the HTTP listener, ECS cluster, and ECS service. The listener wires port 80 to the target group, and the service launches your Fargate task:
# HTTP Listener (temporary forward, converted to redirect in Step 4)
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.payment_api.arn
}
}
# ECS Cluster
resource "aws_ecs_cluster" "main" {
name = "payment-api"
}
# ECS Service
resource "aws_ecs_service" "payment_api" {
name = "payment-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.payment_api.arn
desired_count = 1
launch_type = "FARGATE"
platform_version = "LATEST"
network_configuration {
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.payment_api.arn
container_name = "payment-api"
container_port = 3000
}
}
Why assign_public_ip = true?
Tasks in public subnets with a public IP can reach ECR, Secrets Manager, and CloudWatch directly through the Internet Gateway. This eliminates a NAT Gateway (~$32/month) while the ALB security group restriction keeps the task unreachable from outside.
- Finally, add two output blocks at the end of infra/main.tf so Terraform prints the values you need for the next commands:
output "alb_dns_name" {
value = aws_lb.main.dns_name
}
output "ecr_repository_url" {
value = aws_ecr_repository.payment_api.repository_url
}
- Save infra/main.tf.
- Apply all the load balancer and ECS resources by running:
terraform apply -auto-approve
Terraform creates the ALB, target group, listener, cluster, and service. You should see the alb_dns_name and ecr_repository_url outputs printed at the end.
Will the service be healthy yet?
Not yet. The ECS service is trying to pull the payment-api:latest image from ECR, but you have not pushed anything there. The tasks will fail until you push the image in the next substep.
🙋♀️ Apply failing on a circular dependency?
- If Terraform complains about aws_security_group.alb and aws_security_group.ecs_tasks referencing each other, make sure the ECS tasks SG references the ALB SG (not the other way around). The ALB SG should use cidr_blocks for its ingress, not another security group.
- Verify the aws_security_group.rds block still references aws_security_group.ecs_tasks.id in its ingress.
Help me fix a circular dependency in my Terraform security groups
Push your image and verify the deployment
The infrastructure is ready and the ECS service is waiting for an image. Now you will push your locally built Docker image to ECR and trigger a fresh deployment.
- Authenticate Docker with your ECR registry by running this command (replace the account ID with the one from your terraform output):
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com
Where do I find my account ID?
Run terraform output ecr_repository_url and look at the number before .dkr.ecr. That 12-digit number is your AWS account ID.
You should see Login Succeeded in your terminal.
- Tag your local image with the ECR repository URL and push it by running these commands:
docker tag payment-api:latest $(terraform output -raw ecr_repository_url):latest
docker push $(terraform output -raw ecr_repository_url):latest
The push uploads your image layers to ECR. This may take a minute depending on your internet speed.
- Force the ECS service to pull the new image by triggering a fresh deployment:
aws ecs update-service --cluster payment-api --service payment-api --force-new-deployment
Why force a new deployment?
ECS caches the image digest it last pulled. Without --force-new-deployment, the service would keep trying to use the failed (missing) image reference. Forcing tells ECS to stop existing tasks and launch new ones that pull the freshly pushed image.
- Wait 1-2 minutes for the new task to start and register with the target group.
Before you test, predict: what JSON do you think /health will return?
- Verify your API is alive by curling the ALB DNS:
curl http://$(terraform output -raw alb_dns_name)/health
You should see {"status":"ok"} returned from your container running on Fargate.
- Confirm your migrations ran before the server started by checking CloudWatch Logs. Run:
aws logs tail /ecs/payment-api --since 5m
You should see "Running database migrations..." followed by migration output, then "Starting server..." and "Server listening on port 3000". This proves the entrypoint ran migrations before accepting traffic.
Getting connection refused or no response?
- Check the ECS service events: aws ecs describe-services --cluster payment-api --services payment-api --query 'services[0].events[:5]'. Look for "unable to pull secrets" (IAM policy issue) or "CannotPullContainerError" (image not pushed).
- If the target group shows unhealthy targets, the container might be crashing. Check aws logs tail /ecs/payment-api --since 10m for error messages.
- Verify the target group health check path is /health and your Express app listens on port 3000.
Help me debug why my ECS Fargate task is not starting
✔️ Awesome, I've got everything!
Great. Double check you have saved infra/main.tf and that curl http://<your-alb-dns>/health returns {"status":"ok"}.
ⓧ I'd like to double check the full code
Your infra/main.tf is now quite long. Scroll through and confirm each of these sections is present, in order:
- terraform block + provider + data aws_caller_identity.
- random_password, VPC, Internet Gateway, subnets (2 public, 2 private), route table + associations.
- aws_security_group.ecs_tasks (with ingress from ALB on 3000 + egress all).
- aws_security_group.rds (ingress 5432 from ecs_tasks).
- DB subnet group, RDS instance, Secrets Manager (database + stripe).
- Route 53 zone data source.
- ECR repository, CloudWatch log group, IAM role + policy, ECS task definition.
- ALB security group, ALB, target group, HTTP listener.
- ECS cluster, ECS service, two outputs (alb_dns_name, ecr_repository_url).
Your payment API is live and responding over HTTP. Next up, you will add HTTPS with a TLS certificate and a custom domain so payment data never travels unencrypted.
Configure HTTPS, DNS, and Verify End-to-End Payments
Your API is live and responding to health checks over HTTP. That is a problem for a payment system. Payment data must never traverse the network unencrypted.
In this step you will add TLS termination at the ALB, force all traffic through HTTPS, and verify that end-to-end payments flow correctly through your production deployment. This is also required for Stripe webhook delivery to production URLs.
In this step, get ready to:
- Request and validate a TLS certificate with ACM and Route 53 DNS records.
- Add an HTTPS listener and redirect all HTTP traffic to HTTPS.
- Update Stripe secrets, redeploy, and verify live payment creation and webhook signature verification.
Add Terraform for TLS certificate and DNS validation
ACM issues free TLS certificates for domains you control. The catch is that ACM must prove you own the domain before issuing the certificate, which it does by checking for a specific CNAME record in your DNS zone.
- In infra/main.tf, find the data "aws_route53_zone" "main" block (near the Secrets Manager resources).
- Add the following ACM and DNS validation resources directly below it:
# ACM Certificate
resource "aws_acm_certificate" "main" {
domain_name = "api.yourdomain.click"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
# ACM DNS Validation Records
resource "aws_route53_record" "acm_validation" {
for_each = {
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
zone_id = data.aws_route53_zone.main.zone_id
name = each.value.name
type = each.value.type
ttl = 60
records = [each.value.record]
}
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = [for record in aws_route53_record.acm_validation : record.fqdn]
}
What does this code do?
- aws_acm_certificate requests a new TLS certificate for your API subdomain using DNS validation.
- aws_route53_record "acm_validation" creates the CNAME record(s) that ACM needs to verify you own the domain.
- aws_acm_certificate_validation tells Terraform to wait until ACM confirms the certificate is issued before proceeding.
Remember to replace yourdomain.click with the domain you registered in Step 2.
- Now add the A-record alias that points your subdomain to the ALB. Place this below the validation resource:
# Route 53 A Record alias to ALB
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.main.zone_id
name = "api.yourdomain.click"
type = "A"
alias {
name = aws_lb.main.dns_name
zone_id = aws_lb.main.zone_id
evaluate_target_health = true
}
}
What does the A record do?
This alias record tells DNS to resolve api.yourdomain.click to your ALB's IP addresses.
Unlike a standard A record with a static IP, an alias record dynamically tracks the ALB's changing IPs. The evaluate_target_health = true setting means Route 53 stops routing traffic if the ALB health check fails.
- Save infra/main.tf.
- Apply the changes by running:
cd infra
terraform apply
How long does certificate validation take?
Terraform will wait for ACM to validate the certificate before completing. Since you are using Route 53 (the same DNS service that hosts your zone), validation typically completes within a few minutes.
The default timeout is 75 minutes. If Terraform appears stuck, let it run. ACM is waiting for DNS propagation.
- Confirm terraform apply completes without errors. You should see the new resources created: the ACM certificate, validation records, and the A record.
Terraform timed out or certificate stuck in Pending?
- Verify the domain name in aws_acm_certificate matches a subdomain of the zone in data.aws_route53_zone.main. If your zone is example.click, your cert domain must be api.example.click, not api.otherdomain.com.
- Check the Route 53 console to confirm the CNAME validation record was created in the correct hosted zone.
- help me debug ACM certificate validation
Add HTTPS listener and redirect HTTP traffic
With the certificate issued, you can attach it to an HTTPS listener on the ALB. You will also convert the existing HTTP listener from forwarding traffic to redirecting it to HTTPS.
- In infra/main.tf, find the existing HTTP listener resource:
# HTTP Listener (temporary forward, converted to redirect in Step 4)
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.payment_api.arn
}
}
- Replace the entire aws_lb_listener "http" resource with the following two listener resources:
# HTTP Listener - redirect to HTTPS
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
protocol = "HTTPS"
port = "443"
status_code = "HTTP_301"
}
}
}
# HTTPS Listener
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate_validation.main.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.payment_api.arn
}
}
What changed here?
- The HTTP listener no longer forwards traffic to your containers. Instead it returns a 301 redirect telling the client to retry over HTTPS. No payment data ever flows unencrypted.
- The new HTTPS listener terminates TLS at the ALB using your validated certificate and the ELBSecurityPolicy-TLS13-1-2-2021-06 policy (AWS recommended). Traffic between the ALB and your container stays on the private network over HTTP.
- certificate_arn references the validation resource, which ensures Terraform waits for the cert to be issued before creating the listener.
- Also remove the old comment # Cost checkpoint: RDS db.t4g.micro ~$0.016/hr ($0.38/day). No NAT Gateway saves ~$1.08/day. from the top of the file (it served its purpose in Step 2).
- Save infra/main.tf and apply:
terraform apply
Terraform will modify the HTTP listener in-place and create the new HTTPS listener.
- Before you test, predict: if you curl your domain over HTTP, what HTTP status code should you get back?
- Test the HTTPS redirect by running:
curl -I http://api.yourdomain.click/health
You should see an HTTP 301 response with a Location header pointing to https://api.yourdomain.click/health.
- Now verify HTTPS works end-to-end:
curl -I https://api.yourdomain.click/health
You should see HTTP/2 200 with a valid TLS certificate. Your API is now encrypted in transit.
Seeing a certificate error or connection refused?
- DNS propagation for the A record can take a few minutes. Wait 2-3 minutes and retry.
- Verify your A record exists by running dig api.yourdomain.click and confirming it resolves to the ALB IPs.
- Check that the ssl_policy and certificate_arn in your HTTPS listener reference the correct resources.
- help me troubleshoot my HTTPS listener
Update Stripe secrets and verify end-to-end payments
Your infrastructure is production-ready, but the Stripe secret in Secrets Manager still holds placeholder values from Step 2. The container needs real Stripe test-mode keys to process payments.
- Update the Stripe secret with your real test-mode keys by running:
aws secretsmanager put-secret-value \
--secret-id payment-api/stripe \
--secret-string '{"secret_key":"sk_test_YOUR_KEY","webhook_secret":"whsec_YOUR_SECRET"}'
Where do I find these keys?
Your Stripe test-mode secret key (sk_test_...) is in the Stripe Dashboard under Developers > API Keys. Your webhook signing secret (whsec_...) comes from a webhook endpoint you configured in a prior project, or you can get one from the stripe listen CLI output.
ECS secrets are injected at container start. The running task still has the old placeholder values.
- Force a new deployment so the task picks up the updated secret:
aws ecs update-service \
--cluster payment-api \
--service payment-api \
--force-new-deployment
Why force a new deployment?
Secrets Manager values are read once when the container starts. Updating a secret in Secrets Manager does not automatically propagate to running tasks. The --force-new-deployment flag tells ECS to stop the current task and launch a new one, which reads the fresh secret values at boot.
- Wait 1-2 minutes for the new task to reach a running state. You can check with:
aws ecs describe-services \
--cluster payment-api \
--services payment-api \
--query 'services[0].deployments[0].runningCount'
Once runningCount shows 1, your container is live with the real Stripe keys.
- Test a payment creation against your live deployment:
curl -X POST https://api.yourdomain.click/v1/payments \
-H "Content-Type: application/json" \
-d '{"amount":1000,"currency":"usd"}'
You should receive a success response with a Stripe payment intent ID. Open the Stripe Dashboard and confirm the test charge appears under Payments.
- Finally, test webhook signature verification. In a separate terminal, start the Stripe CLI listener:
stripe listen --forward-to https://api.yourdomain.click/v1/webhooks
- In your original terminal, trigger a test event:
stripe trigger payment_intent.succeeded
- Check CloudWatch Logs for the /ecs/payment-api log group. You should see a log entry confirming the webhook event was received and signature verification passed.
Payment returning an error or webhook signature failing?
- If payment creation fails with a Stripe authentication error, the sk_test_ key in Secrets Manager may be incorrect. Double-check the value in the Stripe Dashboard and re-run put-secret-value followed by another force deployment.
- If webhook signature verification fails, the whsec_ value must match the signing secret shown in your stripe listen output. Update it and redeploy.
- help me debug Stripe webhook signature verification in my ECS deployment
✔️ Awesome, I've got everything!
Great. Double-check you have saved infra/main.tf with all the changes from this step.
ⓧ I'd like to double check the full code
Your infra/main.tf file is now over 200 lines. Scroll through it and confirm these sections are present in order:
- Terraform block with aws and random providers.
- Provider, data sources (aws_caller_identity, aws_route53_zone).
- VPC, subnets, internet gateway, route table.
- Security groups (ALB, ECS tasks, RDS).
- RDS instance and DB subnet group.
- Secrets Manager secrets (database and stripe).
- ACM certificate, DNS validation records, certificate validation resource, A-record alias to ALB.
- ECR repository, CloudWatch log group.
- IAM execution role and policy.
- ECS task definition with secrets block.
- ALB, target group.
- HTTP listener with redirect action (not forward).
- HTTPS listener with ELBSecurityPolicy-TLS13-1-2-2021-06 and certificate ARN.
- ECS cluster and service.
- Outputs (alb_dns_name, ecr_repository_url).
Your payment API is now fully encrypted in transit, your domain resolves to the ALB, and Stripe payments flow end-to-end. Next up, you will protect these endpoints from abuse with WAF rate limiting.
Secret mission
Add WAF Rate Limiting
A payment endpoint exposed to the internet without rate limiting is an invitation for card-testing attacks. Bots rapidly test stolen card numbers against your API. Your mission: add an AWS WAF rule to block abusive traffic at the edge before requests reach your container.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project provisions paid AWS resources that cost approximately $1.26-$1.46 per day while running.
Cost warning
Your infrastructure costs approximately $1.26/day without WAF, or $1.46/day with the Secret Mission WAF rule active. Left running 24/7, that adds up to $38-$44/month.
The domain registration fee ($3-7 depending on TLD) is a one-time annual charge and is non-refundable. However, all other resources can be destroyed with a single terraform destroy command to stop all ongoing charges.
Resources you used:
- RDS PostgreSQL instance (db.t4g.micro) at ~$0.016/hr (~$0.38/day).
- Application Load Balancer at ~$0.0225/hr (~$0.54/day).
- ECS Fargate task (0.25 vCPU / 0.5 GB) at ~$0.012/hr (~$0.29/day).
- Secrets Manager (2 secrets) at ~$0.80/month (~$0.03/day).
- Route 53 hosted zone at $0.50/month (~$0.02/day).
- WAF Web ACL (Secret Mission) at ~$6/month (~$0.20/day).
- ECR repository, CloudWatch log group, VPC, and networking resources (minimal cost while no traffic flows).
- Local project files: infra/ directory, Dockerfile, and docker-entrypoint.sh.
Keep everything running
No action needed. Choose this if you're still actively demonstrating your deployment or building on it.
- Budget approximately $1.30-$1.50 per day while all resources remain active.
- Your HTTPS API stays live at your custom domain for portfolio demonstrations.
- To disable auto-renewal on your domain (preventing next year's charge), run:
aws route53domains disable-domain-auto-renew --region us-east-1 --domain-name yourdomain.click
Why us-east-1 for domains?
Route 53 domain operations are only available in the us-east-1 region, regardless of where your other resources run.
Pause - I'll come back to this later
Shut down the Fargate task to eliminate compute charges while keeping your infrastructure definition intact. RDS and ALB still incur costs (~$0.92/day).
- Stop your ECS task by running:
aws ecs update-service --cluster payment-api --service payment-api --desired-count 0
- Your API will go offline, but all infrastructure (VPC, RDS, ALB, secrets, DNS) remains in place.
- To resume later, set desired count back to 1 and push a fresh image if needed.
- RDS continues charging ~$0.38/day and the ALB ~$0.54/day even while paused.
Stop RDS for deeper savings
You can temporarily stop the RDS instance from the AWS console (it auto-restarts after 7 days). This eliminates the $0.38/day RDS charge while paused.
Delete - I don't want to use this again
Remove all project resources and stop all ongoing charges. This is the recommended option after you have screenshotted the project for your portfolio.
- Navigate to your infra/ directory in your terminal:
cd ~/Desktop/payment-api/infra
- Destroy all Terraform-managed resources by running:
terraform destroy
- Terraform will show you a plan of all resources to be destroyed. Type yes when prompted to confirm.
- This removes: VPC, subnets, RDS instance, ALB, ECS cluster/service/task, ECR repository, Secrets Manager secrets, Route 53 hosted zone records, ACM certificate, WAF Web ACL, CloudWatch log group, and all associated security groups and IAM roles.
- After terraform destroy completes, disable domain auto-renewal to prevent next year's charge:
aws route53domains disable-domain-auto-renew --region us-east-1 --domain-name yourdomain.click
- The domain registration itself is non-refundable (the $3-7 you already paid). The domain stays registered until it expires, but with the hosted zone destroyed, no further DNS charges apply.
- Delete your local project files by running:
cd ~/Desktop
rm -rf payment-api
- Verify cleanup in the AWS console: confirm no ECS clusters, RDS instances, ALBs, or secrets remain under your account in us-east-1.
- Check Cost Explorer the next day to verify all charges have stopped.
Fastest verification path
Search for each service in the AWS console search bar: ECS, RDS, EC2 (for ALBs), Secrets Manager, and WAF. Each page should show zero resources in us-east-1.
That's a Wrap!
That's a Wrap!
Nice work! You just deployed a production-grade Stripe payment API to AWS, accessible over HTTPS with a custom domain, secrets injected securely at runtime, and infrastructure you can reproduce or tear down in a single command.
You've learned how to:
- Deploy a containerized Node.js payment API on ECS Fargate behind an Application Load Balancer with automatic HTTPS redirection and TLS termination using ACM.
- Manage payment credentials securely with AWS Secrets Manager injected directly into containers at boot, with zero plaintext secrets in code or environment files.
- Codify an entire production stack in Terraform for one-command reproducibility and cleanup with terraform destroy.
- (Secret Mission) Protected payment endpoints with WAF rate limiting to block card-testing attacks at the edge before they reach your container.
Ready to quiz yourself?