deploy-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +28 -0
- package/README.md +132 -0
- package/bin/cli.js +27 -0
- package/package.json +26 -0
- package/src/commands/doctor.js +39 -0
- package/src/commands/init.js +331 -0
- package/src/commands/secrets.js +48 -0
- package/src/core/telemetry.js +39 -0
- package/src/utils/system.js +13 -0
- package/templates/README.md +85 -0
- package/templates/_gitignore +19 -0
- package/templates/docker/nextjs.Dockerfile +32 -0
- package/templates/docker/node.Dockerfile +7 -0
- package/templates/docker/python.Dockerfile +7 -0
- package/templates/docker/static.Dockerfile +13 -0
- package/templates/github/deploy.yml +53 -0
- package/templates/terraform/backend.tf +9 -0
- package/templates/terraform/cloudfront.tf +58 -0
- package/templates/terraform/main.tf +186 -0
- package/templates/terraform/network.tf +90 -0
- package/templates/terraform/oidc.tf +62 -0
- package/templates/terraform/secrets.tf +18 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import util from 'util';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const execAsync = util.promisify(exec);
|
|
5
|
+
|
|
6
|
+
export async function checkDependency(command) {
|
|
7
|
+
try {
|
|
8
|
+
await execAsync(`${command} --version`);
|
|
9
|
+
return true;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} - Cloud Infrastructure
|
|
2
|
+
|
|
3
|
+
This project was provisioned by `deploy-stack`. It contains a production-ready AWS ECS Fargate architecture and a GitHub Actions deployment pipeline.
|
|
4
|
+
|
|
5
|
+
## 💰 Cost Estimate & Disclaimer
|
|
6
|
+
|
|
7
|
+
This infrastructure provisions a highly available Application Load Balancer (ALB) and an ECS Fargate container (Size: **{{COMPUTE_TIER}}**).
|
|
8
|
+
|
|
9
|
+
* **Estimated Monthly Cost:** {{ESTIMATED_COST}}
|
|
10
|
+
* *Note: AWS bills by the hour. If you destroy this stack after a few hours of testing, it will cost less than $0.20.*
|
|
11
|
+
|
|
12
|
+
> **⚠️ DISCLAIMER:** This cost is a rough estimate. AWS pricing changes and varies by region. **You are solely responsible for all AWS charges incurred by deploying this infrastructure.** The creators of `deploy-stack` are not liable for unexpected cloud costs, compromised credentials, or runaway billing. Always monitor your AWS Billing Dashboard and set up budget alerts.
|
|
13
|
+
|
|
14
|
+
## 🚀 Deployment Guide
|
|
15
|
+
|
|
16
|
+
1. **Initial Provisioning:**
|
|
17
|
+
```bash
|
|
18
|
+
cd terraform
|
|
19
|
+
terraform init
|
|
20
|
+
terraform apply
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
2. **Push Secrets (Optional):**
|
|
24
|
+
If your application requires environment variables, create a local `.env` file and sync it directly to AWS Secrets Manager:
|
|
25
|
+
```bash
|
|
26
|
+
npx deploy-stack secrets push .env
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
3. **Automated CI/CD (Keyless via OIDC):**
|
|
30
|
+
Push this repository to GitHub. Your deployment pipeline uses AWS IAM OpenID Connect (OIDC) to authenticate securely with temporary credentials—**no long-lived AWS secret keys are required in GitHub Secrets**. Every push to `main` will automatically build, package, and deploy your application.
|
|
31
|
+
|
|
32
|
+
### ⚠️ Troubleshooting: OIDC Provider Already Exists
|
|
33
|
+
AWS only permits one GitHub Actions OIDC provider per AWS account. If `terraform apply` fails with an `EntityAlreadyExists` error regarding the OIDC provider, it indicates GitHub Actions was previously configured in this account.
|
|
34
|
+
|
|
35
|
+
**The Fix:**
|
|
36
|
+
Open `terraform/oidc.tf` and update the default value of `create_oidc_provider` to `false`:
|
|
37
|
+
```hcl
|
|
38
|
+
variable "create_oidc_provider" {
|
|
39
|
+
type = bool
|
|
40
|
+
default = false # <--- Change this from true to false
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
Re-run `terraform apply` to link directly to your existing provider.
|
|
44
|
+
|
|
45
|
+
## 🛑 Safe Teardown (Destroying the Stack)
|
|
46
|
+
|
|
47
|
+
If you are done testing and want to stop all AWS billing, you must destroy the infrastructure.
|
|
48
|
+
|
|
49
|
+
Because our Terraform configuration is set to force-delete the ECR image repository (even if images are present), teardown is a single, clean command:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd terraform
|
|
53
|
+
terraform destroy
|
|
54
|
+
```
|
|
55
|
+
*Type `yes` when prompted. This will permanently delete the Load Balancer, ECS cluster, log groups, and associated networking components.*
|
|
56
|
+
|
|
57
|
+
## ⚠️ Critical Application Prerequisites
|
|
58
|
+
|
|
59
|
+
Before you push your code to GitHub, ensure your application is configured to run inside a Docker container and respond to AWS Load Balancer health checks.
|
|
60
|
+
|
|
61
|
+
### 1. The Health Check Route (All Frameworks)
|
|
62
|
+
|
|
63
|
+
AWS constantly pings your container to ensure it is alive. If you configured a custom health check path (e.g., `/api/health`) during the CLI setup, **you must create that route in your application**. If AWS receives a `404 Not Found`, it will assume your app is broken and terminate the container.
|
|
64
|
+
|
|
65
|
+
Make sure your app returns a `200 OK` at your configured path:
|
|
66
|
+
|
|
67
|
+
* **Next.js (App Router):** Create `app/api/health/route.ts` returning a 200 response.
|
|
68
|
+
* **Express.js:** Add `app.get('/api/health', (req, res) => res.sendStatus(200));`
|
|
69
|
+
* **FastAPI/Python:** Add `@app.get("/api/health")` returning a 200 status.
|
|
70
|
+
|
|
71
|
+
### 2. Enable Standalone Output (Next.js ONLY)
|
|
72
|
+
|
|
73
|
+
Next.js must be configured in "standalone" mode so it can bundle a minimal Node.js server. Without this, your GitHub Actions Docker build will crash.
|
|
74
|
+
|
|
75
|
+
Open `next.config.js` or `next.config.ts` in your root directory and add `output: 'standalone'`:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import type { NextConfig } from 'next';
|
|
79
|
+
|
|
80
|
+
const nextConfig: NextConfig = {
|
|
81
|
+
output: 'standalone', // <--- Add this exact line
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default nextConfig;
|
|
85
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Terraform
|
|
2
|
+
terraform/.terraform/
|
|
3
|
+
terraform/*.tfstate
|
|
4
|
+
terraform/*.tfstate.backup
|
|
5
|
+
terraform/.terraform.lock.hcl
|
|
6
|
+
terraform/secret_keys.json
|
|
7
|
+
terraform/.terraform.*
|
|
8
|
+
|
|
9
|
+
# Node & Next.js
|
|
10
|
+
node_modules/
|
|
11
|
+
.next/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
# Python
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.pyc
|
|
19
|
+
venv/
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Stage 1: Install dependencies and build the app
|
|
2
|
+
FROM node:20-alpine AS builder
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# Copy package files and install dependencies
|
|
6
|
+
COPY package.json package-lock.json* ./
|
|
7
|
+
RUN npm ci
|
|
8
|
+
|
|
9
|
+
# Copy the rest of the application code
|
|
10
|
+
COPY . .
|
|
11
|
+
|
|
12
|
+
# Build the Next.js application
|
|
13
|
+
# (This requires output: 'standalone' in next.config.ts)
|
|
14
|
+
RUN npm run build
|
|
15
|
+
|
|
16
|
+
# Stage 2: Production environment
|
|
17
|
+
FROM node:20-alpine AS runner
|
|
18
|
+
WORKDIR /app
|
|
19
|
+
|
|
20
|
+
ENV NODE_ENV=production
|
|
21
|
+
ENV PORT={{PORT}}
|
|
22
|
+
ENV HOSTNAME="0.0.0.0"
|
|
23
|
+
|
|
24
|
+
# Copy the standalone output from the builder stage
|
|
25
|
+
COPY --from=builder /app/.next/standalone ./
|
|
26
|
+
COPY --from=builder /app/.next/static ./.next/static
|
|
27
|
+
COPY --from=builder /app/public ./public
|
|
28
|
+
|
|
29
|
+
EXPOSE {{PORT}}
|
|
30
|
+
|
|
31
|
+
# Start the standalone Node.js server
|
|
32
|
+
CMD ["node", "server.js"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM nginx:alpine
|
|
2
|
+
|
|
3
|
+
# Remove default Nginx static assets
|
|
4
|
+
RUN rm -rf /usr/share/nginx/html/*
|
|
5
|
+
|
|
6
|
+
# Copy your static build output (e.g., from Gatsby, React, or pure HTML)
|
|
7
|
+
COPY . /usr/share/nginx/html
|
|
8
|
+
|
|
9
|
+
# Dynamically update the Nginx config to use the user's chosen port
|
|
10
|
+
RUN sed -i 's/listen *80;/listen {{PORT}};/g' /etc/nginx/conf.d/default.conf
|
|
11
|
+
|
|
12
|
+
EXPOSE {{PORT}}
|
|
13
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Deploy to AWS ECS
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- {{DEPLOY_BRANCH}}
|
|
7
|
+
|
|
8
|
+
env:
|
|
9
|
+
AWS_REGION: {{REGION}}
|
|
10
|
+
ECR_REPOSITORY: {{PROJECT_NAME}}-repo
|
|
11
|
+
ECS_CLUSTER: {{PROJECT_NAME}}-cluster
|
|
12
|
+
ECS_SERVICE: {{PROJECT_NAME}}-service
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
name: Build & Deploy
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
- name: Checkout Code
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
|
+
|
|
27
|
+
- name: Configure AWS Credentials
|
|
28
|
+
uses: aws-actions/configure-aws-credentials@v4
|
|
29
|
+
with:
|
|
30
|
+
role-to-assume: arn:aws:iam::{{AWS_ACCOUNT_ID}}:role/{{PROJECT_NAME}}-github-actions-role
|
|
31
|
+
aws-region: ${{ env.AWS_REGION }}
|
|
32
|
+
|
|
33
|
+
- name: Login to Amazon ECR
|
|
34
|
+
id: login-ecr
|
|
35
|
+
uses: aws-actions/amazon-ecr-login@v2
|
|
36
|
+
|
|
37
|
+
- name: Build, tag, and push image to Amazon ECR
|
|
38
|
+
id: build-image
|
|
39
|
+
env:
|
|
40
|
+
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
|
41
|
+
IMAGE_TAG: latest
|
|
42
|
+
run: |
|
|
43
|
+
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
|
|
44
|
+
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
|
|
45
|
+
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
|
|
46
|
+
|
|
47
|
+
- name: Force ECS deployment
|
|
48
|
+
run: |
|
|
49
|
+
aws ecs update-service \
|
|
50
|
+
--cluster ${{ env.ECS_CLUSTER }} \
|
|
51
|
+
--service ${{ env.ECS_SERVICE }} \
|
|
52
|
+
--force-new-deployment \
|
|
53
|
+
--region ${{ env.AWS_REGION }}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Fetch the AWS Managed Cache Policy (Optimized for standard web traffic)
|
|
2
|
+
data "aws_cloudfront_cache_policy" "optimized" {
|
|
3
|
+
name = "Managed-CachingOptimized"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
# Fetch the AWS Managed Origin Request Policy (Passes query strings/cookies to your app)
|
|
7
|
+
data "aws_cloudfront_origin_request_policy" "all_viewer" {
|
|
8
|
+
name = "Managed-AllViewerExceptHostHeader"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
resource "aws_cloudfront_distribution" "cdn" {
|
|
12
|
+
enabled = true
|
|
13
|
+
is_ipv6_enabled = true
|
|
14
|
+
wait_for_deployment = false # Prevents Terraform from hanging for 10+ minutes
|
|
15
|
+
|
|
16
|
+
origin {
|
|
17
|
+
# Point the CDN at the Load Balancer we provision in main.tf
|
|
18
|
+
domain_name = aws_lb.main.dns_name
|
|
19
|
+
origin_id = "ALBOrigin"
|
|
20
|
+
|
|
21
|
+
custom_origin_config {
|
|
22
|
+
http_port = 80
|
|
23
|
+
https_port = 443
|
|
24
|
+
# Because this is the free tier without a custom SSL domain, we route internally via HTTP
|
|
25
|
+
origin_protocol_policy = "http-only"
|
|
26
|
+
origin_ssl_protocols = ["TLSv1.2"]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
default_cache_behavior {
|
|
31
|
+
# Allow all HTTP methods so API POST/PUT requests still work
|
|
32
|
+
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
|
|
33
|
+
cached_methods = ["GET", "HEAD"]
|
|
34
|
+
target_origin_id = "ALBOrigin"
|
|
35
|
+
|
|
36
|
+
viewer_protocol_policy = "redirect-to-https"
|
|
37
|
+
|
|
38
|
+
# Attach the Managed Policies
|
|
39
|
+
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
|
|
40
|
+
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
restrictions {
|
|
44
|
+
geo_restriction {
|
|
45
|
+
restriction_type = "none"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
viewer_certificate {
|
|
50
|
+
# Gives the user a free HTTPS *.cloudfront.net domain out of the box
|
|
51
|
+
cloudfront_default_certificate = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
output "cloudfront_url" {
|
|
56
|
+
description = "Your globally cached, HTTPS-secured application URL"
|
|
57
|
+
value = "https://${aws_cloudfront_distribution.cdn.domain_name}"
|
|
58
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# deploy-stack generated infrastructure
|
|
2
|
+
provider "aws" {
|
|
3
|
+
region = "{{REGION}}"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
locals {
|
|
7
|
+
# Safely read the keys generated by the CLI, default to empty list if file doesn't exist
|
|
8
|
+
secret_keys = fileexists("${path.module}/secret_keys.json") ? jsondecode(file("${path.module}/secret_keys.json")) : []
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
# --- CloudWatch Logs ---
|
|
12
|
+
resource "aws_cloudwatch_log_group" "app_logs" {
|
|
13
|
+
name = "/ecs/{{PROJECT_NAME}}"
|
|
14
|
+
retention_in_days = 14
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# --- ECR Repository ---
|
|
18
|
+
resource "aws_ecr_repository" "app" {
|
|
19
|
+
name = "{{PROJECT_NAME}}-repo"
|
|
20
|
+
image_tag_mutability = "MUTABLE"
|
|
21
|
+
force_delete = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# --- IAM: Execution Role ---
|
|
25
|
+
# Allows the underlying AWS Fargate agent to pull images and push logs
|
|
26
|
+
data "aws_iam_policy_document" "ecs_trust" {
|
|
27
|
+
statement {
|
|
28
|
+
actions = ["sts:AssumeRole"]
|
|
29
|
+
principals {
|
|
30
|
+
type = "Service"
|
|
31
|
+
identifiers = ["ecs-tasks.amazonaws.com"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
resource "aws_iam_role" "execution_role" {
|
|
37
|
+
name = "{{PROJECT_NAME}}-execution-role"
|
|
38
|
+
assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
resource "aws_iam_role_policy_attachment" "execution_role_policy" {
|
|
42
|
+
role = aws_iam_role.execution_role.name
|
|
43
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# --- IAM: Task Role ---
|
|
47
|
+
# Allows your application code running INSIDE the container to access AWS services
|
|
48
|
+
resource "aws_iam_role" "task_role" {
|
|
49
|
+
name = "{{PROJECT_NAME}}-task-role"
|
|
50
|
+
assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# --- ECS Cluster ---
|
|
54
|
+
resource "aws_ecs_cluster" "main" {
|
|
55
|
+
name = "{{PROJECT_NAME}}-cluster"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# --- ECS Task Definition ---
|
|
59
|
+
resource "aws_ecs_task_definition" "app" {
|
|
60
|
+
family = "{{PROJECT_NAME}}-task"
|
|
61
|
+
network_mode = "awsvpc"
|
|
62
|
+
requires_compatibilities = ["FARGATE"]
|
|
63
|
+
cpu = "{{CPU}}"
|
|
64
|
+
memory = "{{MEMORY}}"
|
|
65
|
+
execution_role_arn = aws_iam_role.execution_role.arn
|
|
66
|
+
task_role_arn = aws_iam_role.task_role.arn
|
|
67
|
+
|
|
68
|
+
container_definitions = jsonencode([
|
|
69
|
+
{
|
|
70
|
+
name = "{{PROJECT_NAME}}-container"
|
|
71
|
+
image = "${aws_ecr_repository.app.repository_url}:latest"
|
|
72
|
+
essential = true
|
|
73
|
+
|
|
74
|
+
# Dynamically map every secret key found in the local JSON file
|
|
75
|
+
secrets = [
|
|
76
|
+
for key in local.secret_keys : {
|
|
77
|
+
name = key
|
|
78
|
+
valueFrom = "${aws_secretsmanager_secret.app_secrets.arn}:${key}::"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
portMappings = [
|
|
83
|
+
{
|
|
84
|
+
containerPort = {{PORT}}
|
|
85
|
+
hostPort = {{PORT}}
|
|
86
|
+
protocol = "tcp"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
logConfiguration = {
|
|
91
|
+
logDriver = "awslogs"
|
|
92
|
+
options = {
|
|
93
|
+
"awslogs-group" = aws_cloudwatch_log_group.app_logs.name
|
|
94
|
+
"awslogs-region" = "{{REGION}}"
|
|
95
|
+
"awslogs-stream-prefix" = "ecs"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# --- Application Load Balancer ---
|
|
103
|
+
resource "aws_lb" "main" {
|
|
104
|
+
name = "{{PROJECT_NAME}}-alb"
|
|
105
|
+
load_balancer_type = "application"
|
|
106
|
+
security_groups = [aws_security_group.alb.id]
|
|
107
|
+
subnets = aws_subnet.public[*].id
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
resource "aws_lb_target_group" "app" {
|
|
111
|
+
name = "{{PROJECT_NAME}}-tg"
|
|
112
|
+
port = {{PORT}}
|
|
113
|
+
protocol = "HTTP"
|
|
114
|
+
vpc_id = aws_vpc.main.id
|
|
115
|
+
target_type = "ip"
|
|
116
|
+
|
|
117
|
+
health_check {
|
|
118
|
+
path = "{{HEALTH_CHECK_PATH}}"
|
|
119
|
+
matcher = "200-399"
|
|
120
|
+
interval = 30
|
|
121
|
+
timeout = 5
|
|
122
|
+
healthy_threshold = 2
|
|
123
|
+
unhealthy_threshold = 3
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
resource "aws_lb_listener" "http" {
|
|
128
|
+
load_balancer_arn = aws_lb.main.arn
|
|
129
|
+
port = "80"
|
|
130
|
+
protocol = "HTTP"
|
|
131
|
+
|
|
132
|
+
default_action {
|
|
133
|
+
type = "forward"
|
|
134
|
+
target_group_arn = aws_lb_target_group.app.arn
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# --- ECS Service ---
|
|
139
|
+
resource "aws_ecs_service" "app" {
|
|
140
|
+
name = "{{PROJECT_NAME}}-service"
|
|
141
|
+
cluster = aws_ecs_cluster.main.id
|
|
142
|
+
task_definition = aws_ecs_task_definition.app.arn
|
|
143
|
+
launch_type = "FARGATE"
|
|
144
|
+
desired_count = {{DESIRED_COUNT}}
|
|
145
|
+
|
|
146
|
+
network_configuration {
|
|
147
|
+
subnets = aws_subnet.public[*].id
|
|
148
|
+
security_groups = [aws_security_group.ecs_tasks.id]
|
|
149
|
+
assign_public_ip = true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
load_balancer {
|
|
153
|
+
target_group_arn = aws_lb_target_group.app.arn
|
|
154
|
+
container_name = "{{PROJECT_NAME}}-container"
|
|
155
|
+
container_port = {{PORT}}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
depends_on = [aws_lb_listener.http]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Allow the ECS agent to read the specific secret from Secrets Manager
|
|
162
|
+
resource "aws_iam_role_policy" "secrets_policy" {
|
|
163
|
+
name = "{{PROJECT_NAME}}-secrets-policy"
|
|
164
|
+
role = aws_iam_role.execution_role.id
|
|
165
|
+
policy = jsonencode({
|
|
166
|
+
Version = "2012-10-17"
|
|
167
|
+
Statement = [
|
|
168
|
+
{
|
|
169
|
+
Effect = "Allow"
|
|
170
|
+
Action = ["secretsmanager:GetSecretValue"]
|
|
171
|
+
Resource = [aws_secretsmanager_secret.app_secrets.arn]
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# --- Outputs ---
|
|
178
|
+
output "website_url" {
|
|
179
|
+
description = "The public URL of your load balancer"
|
|
180
|
+
value = "http://${aws_lb.main.dns_name}"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
output "ecr_repository_url" {
|
|
184
|
+
description = "The URL of the ECR repository"
|
|
185
|
+
value = aws_ecr_repository.app.repository_url
|
|
186
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# --- VPC & Networking ---
|
|
2
|
+
resource "aws_vpc" "main" {
|
|
3
|
+
cidr_block = "10.0.0.0/16"
|
|
4
|
+
enable_dns_hostnames = true
|
|
5
|
+
enable_dns_support = true
|
|
6
|
+
|
|
7
|
+
tags = {
|
|
8
|
+
Name = "{{PROJECT_NAME}}-vpc"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
# Fetch available zones in the current region
|
|
13
|
+
data "aws_availability_zones" "available" {
|
|
14
|
+
state = "available"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
resource "aws_internet_gateway" "main" {
|
|
18
|
+
vpc_id = aws_vpc.main.id
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Create 2 Public Subnets
|
|
22
|
+
resource "aws_subnet" "public" {
|
|
23
|
+
count = 2
|
|
24
|
+
vpc_id = aws_vpc.main.id
|
|
25
|
+
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
|
|
26
|
+
availability_zone = data.aws_availability_zones.available.names[count.index]
|
|
27
|
+
map_public_ip_on_launch = true
|
|
28
|
+
|
|
29
|
+
tags = {
|
|
30
|
+
Name = "{{PROJECT_NAME}}-public-subnet-${count.index + 1}"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Route internet traffic through the Gateway
|
|
35
|
+
resource "aws_route_table" "public" {
|
|
36
|
+
vpc_id = aws_vpc.main.id
|
|
37
|
+
route {
|
|
38
|
+
cidr_block = "0.0.0.0/0"
|
|
39
|
+
gateway_id = aws_internet_gateway.main.id
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resource "aws_route_table_association" "public" {
|
|
44
|
+
count = 2
|
|
45
|
+
subnet_id = aws_subnet.public[count.index].id
|
|
46
|
+
route_table_id = aws_route_table.public.id
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# --- Security Groups ---
|
|
50
|
+
# ALB SG: Allow public internet access on HTTP
|
|
51
|
+
resource "aws_security_group" "alb" {
|
|
52
|
+
name = "{{PROJECT_NAME}}-alb-sg"
|
|
53
|
+
description = "Allow inbound HTTP to ALB"
|
|
54
|
+
vpc_id = aws_vpc.main.id
|
|
55
|
+
|
|
56
|
+
ingress {
|
|
57
|
+
from_port = 80
|
|
58
|
+
to_port = 80
|
|
59
|
+
protocol = "tcp"
|
|
60
|
+
cidr_blocks = ["0.0.0.0/0"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
egress {
|
|
64
|
+
from_port = 0
|
|
65
|
+
to_port = 0
|
|
66
|
+
protocol = "-1"
|
|
67
|
+
cidr_blocks = ["0.0.0.0/0"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# ECS SG: Allow traffic ONLY from the ALB on the app's specific port
|
|
72
|
+
resource "aws_security_group" "ecs_tasks" {
|
|
73
|
+
name = "{{PROJECT_NAME}}-ecs-sg"
|
|
74
|
+
description = "Allow inbound access from the ALB only"
|
|
75
|
+
vpc_id = aws_vpc.main.id
|
|
76
|
+
|
|
77
|
+
ingress {
|
|
78
|
+
from_port = {{PORT}}
|
|
79
|
+
to_port = {{PORT}}
|
|
80
|
+
protocol = "tcp"
|
|
81
|
+
security_groups = [aws_security_group.alb.id]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
egress {
|
|
85
|
+
from_port = 0
|
|
86
|
+
to_port = 0
|
|
87
|
+
protocol = "-1"
|
|
88
|
+
cidr_blocks = ["0.0.0.0/0"]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Fetch GitHub's OIDC certificate thumbprint
|
|
2
|
+
data "tls_certificate" "github" {
|
|
3
|
+
url = "https://token.actions.githubusercontent.com"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "create_oidc_provider" {
|
|
7
|
+
description = "Set to false if you already have OIDC provider in your AWS account"
|
|
8
|
+
type = bool
|
|
9
|
+
default = true
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
# Register GitHub as an Identity Provider in AWS
|
|
13
|
+
resource "aws_iam_openid_connect_provider" "github" {
|
|
14
|
+
count = var.create_oidc_provider ? 1 : 0
|
|
15
|
+
url = "https://token.actions.githubusercontent.com"
|
|
16
|
+
client_id_list = ["sts.amazonaws.com"]
|
|
17
|
+
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Reference the Provider (whether newly created or already existing)
|
|
21
|
+
data "aws_iam_openid_connect_provider" "github_existing" {
|
|
22
|
+
count = var.create_oidc_provider ? 0 : 1
|
|
23
|
+
url = "https://token.actions.githubusercontent.com"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Dynamically pick the ARN of the provider based on the variable
|
|
27
|
+
locals {
|
|
28
|
+
github_provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github_existing[0].arn
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Create the IAM Role that GitHub Actions will assume
|
|
32
|
+
resource "aws_iam_role" "github_actions" {
|
|
33
|
+
name = "{{PROJECT_NAME}}-github-actions-role"
|
|
34
|
+
|
|
35
|
+
assume_role_policy = jsonencode({
|
|
36
|
+
Version = "2012-10-17"
|
|
37
|
+
Statement = [
|
|
38
|
+
{
|
|
39
|
+
Action = "sts:AssumeRoleWithWebIdentity"
|
|
40
|
+
Effect = "Allow"
|
|
41
|
+
Principal = {
|
|
42
|
+
Federated = local.github_provider_arn
|
|
43
|
+
}
|
|
44
|
+
Condition = {
|
|
45
|
+
StringLike = {
|
|
46
|
+
# ⚠️ SECURITY: Update "repo:*" to "repo:your-github-username/your-repo-name:*" in production
|
|
47
|
+
"token.actions.githubusercontent.com:sub" : "repo:*"
|
|
48
|
+
}
|
|
49
|
+
StringEquals = {
|
|
50
|
+
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Grant the CI/CD pipeline permissions to provision infrastructure
|
|
59
|
+
resource "aws_iam_role_policy_attachment" "github_actions_admin" {
|
|
60
|
+
role = aws_iam_role.github_actions.name
|
|
61
|
+
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
|
|
62
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# --- AWS Secrets Manager ---
|
|
2
|
+
resource "aws_secretsmanager_secret" "app_secrets" {
|
|
3
|
+
name = "{{PROJECT_NAME}}-secrets"
|
|
4
|
+
description = "Environment variables for {{PROJECT_NAME}}"
|
|
5
|
+
recovery_window_in_days = 0 # Allows instant deletion for dev/POC environments
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
# Initial placeholder secret so the ECS task doesn't fail on first boot
|
|
9
|
+
resource "aws_secretsmanager_secret_version" "app_secrets_initial" {
|
|
10
|
+
secret_id = aws_secretsmanager_secret.app_secrets.id
|
|
11
|
+
secret_string = jsonencode({
|
|
12
|
+
EXAMPLE_API_KEY = "replace_me_in_aws_console_or_cli"
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
lifecycle {
|
|
16
|
+
ignore_changes = [secret_string]
|
|
17
|
+
}
|
|
18
|
+
}
|