From 575c711d3498b6641becb82b59093d22d974fb90 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 13:22:21 -0500 Subject: [PATCH 01/10] update PHASE_A_INFRASTRUCTURE with real-world AWS console fixes - Fix security group names (sg- prefix not allowed by AWS) - Add OIDC provider skip note (reuse existing from prior project) - Switch RDS template to Dev/Test with db.t3.micro and 25 GiB gp3 - Add EC2 subnet warning (must use public subnet tinyurl-public-1a) - Add Elastic IP allocation steps (auto-assign public IP did not work) - Add SSM Fleet Manager context and why it matters for CI/CD - Update CloudFront setup with new console flow (pay-as-you-go, WAF off) - Update S3 bucket creation settings - Add CloudFront distribution ID and domain name - Remove manual S3 bucket policy step (CloudFront adds it automatically) --- docs/deployment/PHASE_A_INFRASTRUCTURE.md | 141 +++++++++++++--------- 1 file changed, 84 insertions(+), 57 deletions(-) diff --git a/docs/deployment/PHASE_A_INFRASTRUCTURE.md b/docs/deployment/PHASE_A_INFRASTRUCTURE.md index 986093c..4f45b95 100644 --- a/docs/deployment/PHASE_A_INFRASTRUCTURE.md +++ b/docs/deployment/PHASE_A_INFRASTRUCTURE.md @@ -120,30 +120,35 @@ For each subnet: Create three security groups inside `tinyurl-prod-vpc`. -### sg-alb (Internet-facing load balancer) +### tinyurl-alb (Internet-facing load balancer) + +> AWS does not allow security group names starting with `sg-` — use names without that prefix. 1. **VPC → Security Groups → Create security group** -2. Name: `sg-tinyurl-alb`, VPC: `tinyurl-prod-vpc` -3. Inbound rules: +2. Name: `tinyurl-alb`, VPC: `tinyurl-prod-vpc` +3. Description: `Internet-facing load balancer` +4. Inbound rules: - HTTP (80) from `0.0.0.0/0` - HTTPS (443) from `0.0.0.0/0` -4. Outbound: All traffic (default) +5. Outbound: All traffic (default) -### sg-ec2 (Application server) +### tinyurl-ec2 (Application server) -1. Name: `sg-tinyurl-ec2`, VPC: `tinyurl-prod-vpc` -2. Inbound rules: - - HTTP (80) from source: `sg-tinyurl-alb` (not `0.0.0.0/0` — only ALB can reach EC2) -3. Outbound: All traffic (default) +1. Name: `tinyurl-ec2`, VPC: `tinyurl-prod-vpc` +2. Description: `Application server, accepts traffic from ALB only` +3. Inbound rules: + - HTTP (80) from source: `tinyurl-alb` (not `0.0.0.0/0` — only ALB can reach EC2) +4. Outbound: All traffic (default) > No port 22. SSH is not used. EC2 access is via SSM Session Manager only. -### sg-rds (Database) +### tinyurl-rds (Database) -1. Name: `sg-tinyurl-rds`, VPC: `tinyurl-prod-vpc` -2. Inbound rules: - - PostgreSQL (5432) from source: `sg-tinyurl-ec2` -3. Outbound: None (remove default rule) +1. Name: `tinyurl-rds`, VPC: `tinyurl-prod-vpc` +2. Description: `Database, accepts traffic from EC2 only` +3. Inbound rules: + - PostgreSQL (5432) from source: `tinyurl-ec2` +4. Outbound: None (remove default rule) --- @@ -182,6 +187,8 @@ This role lets GitHub Actions deploy without storing any AWS keys. **2a. Create OIDC provider:** +> Skip this step if the provider already exists. Go to **IAM → Identity providers** and check if `token.actions.githubusercontent.com` is already listed (likely from a previous project). If it is, proceed directly to 2b. + 1. Go to **IAM → Identity providers → Add provider** 2. Provider type: **OpenID Connect** 3. Provider URL: `https://token.actions.githubusercontent.com` @@ -245,22 +252,28 @@ This role lets GitHub Actions deploy without storing any AWS keys. ### 6b. Create RDS instance 1. Go to **RDS → Databases → Create database** -2. Engine: **PostgreSQL**, version: **16.x** (latest 16) -3. Template: **Free tier** (selects db.t3.micro automatically — change to db.t3.micro if not) +2. Engine: **PostgreSQL**, version: **17.x** (latest 17) +3. Template: **Free Tier** (visible for standard PostgreSQL — you will still be billed since account free tier has expired, but this template forces db.t3.micro) 4. DB instance identifier: `tinyurl-prod` 5. Master username: `tinyurl` -6. Master password: generate a strong random password — **save this, you will put it in SSM** -7. Instance class: `db.t3.micro` -8. Storage: 5 GB gp3, enable auto-scaling (max 20 GB) -9. VPC: `tinyurl-prod-vpc` -10. DB subnet group: `tinyurl-rds-subnet-group` -11. Public access: **No** -12. VPC security group: `sg-tinyurl-rds` -13. Initial database name: `tinyurl` -14. Automated backups: Enabled, 7-day retention -15. Encryption: Enabled (AWS managed key) -16. Deletion protection: **Enabled** -17. Click **Create database** — takes ~5 minutes +6. Master password: **Self managed** — generate with `openssl rand -base64 32`, save it, you will put it in SSM Parameter Store in Phase B +7. Database authentication: **Password authentication** +8. Instance class: `db.t3.micro` (selected automatically by Free Tier template) +9. Multi-AZ: **Disable** (not needed for this project, saves ~$13/month) +10. Storage: 25 GiB gp3, disable auto-scaling (minimum enforced by AWS is 20 GiB) +11. Connectivity: **Don't connect to an EC2 compute resource** (EC2 not created yet — connectivity handled via security groups) +12. Network type: **IPv4** +13. VPC: `tinyurl-prod-vpc` +14. DB subnet group: `tinyurl-rds-subnet-group` +15. Public access: **No** +16. VPC security group: `tinyurl-rds` +17. Initial database name: `tinyurl_production_db` +18. Performance Insights: **Enable**, retention **7 days** (free tier) — or disable entirely +19. Certificate authority: `rds-ca-rsa2048-g1` (default — free, enables TLS) +20. Automated backups: Enabled, 7-day retention +21. Encryption: Enabled (AWS managed key) +22. Deletion protection: **Enabled** +23. Click **Create database** — takes ~5 minutes > After creation, copy the **Endpoint** (e.g. `tinyurl-prod.xyz.us-east-1.rds.amazonaws.com`). You will need it for SSM Parameter Store in Phase B. @@ -271,13 +284,15 @@ This role lets GitHub Actions deploy without storing any AWS keys. 1. Go to **EC2 → Launch instance** 2. Name: `tinyurl-prod` 3. AMI: **Ubuntu Server 22.04 LTS** (64-bit x86) -4. Instance type: `t3.small` +4. Instance type: `t3.small` (~$15/month — 2 GB RAM needed to run Docker + Spring Boot + Nginx without OOM) 5. Key pair: **Proceed without a key pair** (access is via SSM — no SSH needed) 6. Network settings: - VPC: `tinyurl-prod-vpc` - - Subnet: `tinyurl-public-1a` (us-east-1a) + - Subnet: **`tinyurl-public-1a`** (`10.0.1.0/24`) — must be a public subnet, not private - Auto-assign public IP: **Enable** - - Security group: `sg-tinyurl-ec2` + - Security group: `tinyurl-ec2` + +> **Critical:** Make sure the subnet selected shows CIDR `10.0.1.0/24`. Using a private subnet (`10.0.3.x`) will prevent SSM from reaching AWS endpoints and the instance will not appear in Fleet Manager. 7. Storage: 20 GB gp3 8. Advanced details → IAM instance profile: `role-tinyurl-ec2` 9. User data (paste this — installs Docker on first boot): @@ -295,6 +310,8 @@ mkdir -p /app 10. Click **Launch instance** > After launch, go to **Systems Manager → Fleet Manager**. Within 2–3 minutes the instance should appear as **Online**. This confirms SSM Session Manager is working and you can connect without SSH. +> +> **Why SSM matters:** No SSH or key pair needed — access is via AWS console. GitHub Actions uses SSM `SendCommand` in Phase D to trigger deployments on the EC2 instance. Without SSM working, automated deployments will not work. --- @@ -341,46 +358,56 @@ mkdir -p /app 1. Go to **S3 → Create bucket** 2. Bucket name: `tinyurl-spa-prod` 3. Region: `us-east-1` -4. Block all public access: **On** (all four checkboxes) -5. Versioning: Disabled -6. Encryption: SSE-S3 (default) -7. Click **Create bucket** +4. Bucket type: **General purpose** +5. Object ownership: **ACLs disabled** (recommended) +6. Block all public access: **On** (all four checkboxes) +7. Versioning: **Disabled** +8. Encryption: **SSE-S3** (default), Bucket Key: **Enable** +9. Click **Create bucket** > Do not enable static website hosting — CloudFront handles routing. +> The S3 bucket policy will be automatically added by CloudFront when you select "Allow private S3 bucket access" during distribution creation. --- ## Step 10 — CloudFront Distribution 1. Go to **CloudFront → Create distribution** -2. Origin domain: select `tinyurl-spa-prod.s3.us-east-1.amazonaws.com` -3. Origin access: **Origin access control settings (recommended)** - - Click **Create new OAC**, name: `tinyurl-spa-oac`, sign requests: Yes - - After creation, copy the S3 bucket policy that CloudFront shows — you will apply it in the next step -4. Default cache behavior: - - Viewer protocol policy: **Redirect HTTP to HTTPS** - - Cache policy: `CachingOptimized` -5. Settings: - - Price class: **Use only North America and Europe** - - Alternate domain names (CNAMEs): `tinyurl.buffden.com` - - Custom SSL certificate: select `*.buffden.com` - - Default root object: `index.html` -6. Click **Create distribution** - -**Apply S3 bucket policy (OAC):** - -1. Go to **S3 → tinyurl-spa-prod → Permissions → Bucket policy** -2. Paste the policy CloudFront generated in step 3 above -3. Save + - If a pricing plan popup appears, select **Pay-as-you-go** (no commitment, costs under $1/month for a portfolio project — AWS Shield Standard DDoS protection is included free regardless) +2. Distribution name: `tinyurl-spa-prod` +3. Distribution type: **Single website or app** +4. Route 53 managed domain: leave blank +5. Origin type: **Amazon S3** +6. S3 origin: `tinyurl-spa-prod.s3.us-east-1.amazonaws.com` +7. Origin path: leave blank +8. Allow private S3 bucket access: **Allow private S3 bucket access to CloudFront (Recommended)** — CloudFront will automatically update the S3 bucket policy +9. Origin settings: **Use recommended origin settings** +10. Cache settings: **Use recommended cache settings tailored to serving S3 content** +11. WAF (Enable security protections): **Do not enable security protections** — $14/month not worth it for a portfolio project. AWS Shield Standard DDoS protection is free and automatic. +12. After creation, go to **Settings → Edit** and configure: + - **Alternate domain names**: `tinyurl.buffden.com` + - **Custom SSL certificate**: `*.buffden.com` + - **Default root object**: `index.html` + - **Price class**: **Use only North America and Europe** + - **IPv6**: **On** (free, no downside) + - Click **Save changes** **Add custom error pages (required for Angular routing):** -1. CloudFront → your distribution → **Error pages → Create custom error response** -2. HTTP error code: **403** → Response page path: `/index.html` → HTTP response code: **200** -3. Repeat for HTTP error code: **404** +1. CloudFront → your distribution → **Error pages tab → Create custom error response** +2. HTTP error code: **403** + - Customize error response: **Yes** + - Response page path: `/index.html` + - HTTP response code: **200** + - Click **Create** +3. Repeat exactly the same for HTTP error code: **404** > These error pages are critical. Without them, refreshing any Angular route (e.g. `/dashboard`) will return a 403/404 from S3 instead of serving the SPA. +> CloudFront automatically updates the S3 bucket policy when you select "Allow private S3 bucket access" — no manual bucket policy update needed. +> +> Distribution created: ID `E2JO8EQWSPGUL5`, domain `d1anlbbmfo4elu.cloudfront.net` + --- ## Step 11 — Route 53 DNS Records From 59af203a8b34b16ba61f0083ea0ace8ee1a24ae7 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 13:56:18 -0500 Subject: [PATCH 02/10] update in doc phase A of deployment --- docs/deployment/PHASE_A_INFRASTRUCTURE.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/deployment/PHASE_A_INFRASTRUCTURE.md b/docs/deployment/PHASE_A_INFRASTRUCTURE.md index 4f45b95..497ff65 100644 --- a/docs/deployment/PHASE_A_INFRASTRUCTURE.md +++ b/docs/deployment/PHASE_A_INFRASTRUCTURE.md @@ -342,7 +342,7 @@ mkdir -p /app 4. IP address type: IPv4 5. VPC: `tinyurl-prod-vpc` 6. Subnets: select both public subnets (`tinyurl-public-1a`, `tinyurl-public-1b`) -7. Security groups: `sg-tinyurl-alb` +7. Security groups: `tinyurl-alb` 8. Listeners: - Port 80: **Add listener** → Action: Redirect to HTTPS (443), status 301 - Port 443: **Add listener** → Action: Forward to `tg-tinyurl-api` @@ -405,8 +405,6 @@ mkdir -p /app > These error pages are critical. Without them, refreshing any Angular route (e.g. `/dashboard`) will return a 403/404 from S3 instead of serving the SPA. > CloudFront automatically updates the S3 bucket policy when you select "Allow private S3 bucket access" — no manual bucket policy update needed. -> -> Distribution created: ID `E2JO8EQWSPGUL5`, domain `d1anlbbmfo4elu.cloudfront.net` --- From 112c639e931cf83ceb79e9e45e19c9f3c4d36cc5 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 13:56:39 -0500 Subject: [PATCH 03/10] add Spring Cloud AWS SSM dependency and production config Add spring-cloud-aws-starter-parameter-store to build.gradle.kts, configure application-prod.yaml to import secrets from SSM at startup, and add CORS allowed-origins property to application.yaml for dev. --- tinyurl/build.gradle.kts | 2 ++ tinyurl/src/main/resources/application-prod.yaml | 9 +++++++++ tinyurl/src/main/resources/application.yaml | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tinyurl/build.gradle.kts b/tinyurl/build.gradle.kts index 6fd08ae..a8c8315 100644 --- a/tinyurl/build.gradle.kts +++ b/tinyurl/build.gradle.kts @@ -25,6 +25,8 @@ repositories { } dependencies { + implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1")) + implementation("io.awspring.cloud:spring-cloud-aws-starter-parameter-store") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") diff --git a/tinyurl/src/main/resources/application-prod.yaml b/tinyurl/src/main/resources/application-prod.yaml index 9a3dc92..1899b6b 100644 --- a/tinyurl/src/main/resources/application-prod.yaml +++ b/tinyurl/src/main/resources/application-prod.yaml @@ -1,7 +1,16 @@ # Production-specific configuration +spring: + config: + import: "aws-parameterstore:/tinyurl/prod/" + management: endpoints: web: exposure: # Production: only expose health endpoint. Metrics/prometheus require separate admin port + authentication include: health + +tinyurl: + cors: + allowed-origins: + - "https://tinyurl.buffden.com" diff --git a/tinyurl/src/main/resources/application.yaml b/tinyurl/src/main/resources/application.yaml index 04137e9..7c32ad7 100644 --- a/tinyurl/src/main/resources/application.yaml +++ b/tinyurl/src/main/resources/application.yaml @@ -44,4 +44,6 @@ tinyurl: base-url: ${TINYURL_BASE_URL:http://localhost} default-expiry-days: ${TINYURL_DEFAULT_EXPIRY_DAYS:180} short-code-min-length: ${TINYURL_SHORT_CODE_MIN_LENGTH:6} - + cors: + allowed-origins: + - "http://localhost:4200" From eb22d706b882bb6d12b5f2d0b5942c1082a07b36 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 13:56:51 -0500 Subject: [PATCH 04/10] add CorsConfig to allow cross-origin requests from the frontend --- .../java/com/tinyurl/config/CorsConfig.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java diff --git a/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java b/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java new file mode 100644 index 0000000..fd67442 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java @@ -0,0 +1,31 @@ +package com.tinyurl.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Value("${tinyurl.cors.allowed-origins}") + private List allowedOrigins; + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(allowedOrigins); + config.setAllowedMethods(List.of("GET", "POST", "OPTIONS")); + config.setAllowedHeaders(List.of("Content-Type", "Accept")); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + + return new CorsFilter(source); + } +} From 354d14282c6c8e3a5cf1a7790303ff3bba64400a Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 13:57:15 -0500 Subject: [PATCH 05/10] add docker-compose.prod.yml and nginx reverse proxy config Production-only compose file runs nginx (rate limiting, security headers, actuator blocking) in front of the Spring Boot app. Logs ship to CloudWatch via the awslogs driver. --- docker-compose.prod.yml | 45 +++++++++++++++++++++++++++++++++++++ infra/nginx/nginx.prod.conf | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docker-compose.prod.yml create mode 100644 infra/nginx/nginx.prod.conf diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..08e8987 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,45 @@ +# docker-compose.prod.yml +# Production only — no Postgres (using RDS), no Flyway (migrations run at deploy time) +# Deployed on EC2, managed via SSM RunCommand +# Required env vars on EC2: IMAGE_TAG, RDS_ENDPOINT + +services: + nginx: + image: nginx:1.27-alpine + container_name: tinyurl-nginx + ports: + - "80:80" + volumes: + - ./infra/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + depends_on: + app: + condition: service_healthy + restart: unless-stopped + logging: + driver: awslogs + options: + awslogs-group: /tinyurl/prod + awslogs-region: us-east-1 + awslogs-stream: nginx + + app: + image: ghcr.io/buffden/tinyurl-api:${IMAGE_TAG} + container_name: tinyurl-app + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: jdbc:postgresql://${RDS_ENDPOINT}:5432/tinyurl_production_db + expose: + - "8080" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + restart: unless-stopped + logging: + driver: awslogs + options: + awslogs-group: /tinyurl/prod + awslogs-region: us-east-1 + awslogs-stream: app diff --git a/infra/nginx/nginx.prod.conf b/infra/nginx/nginx.prod.conf new file mode 100644 index 0000000..85b1483 --- /dev/null +++ b/infra/nginx/nginx.prod.conf @@ -0,0 +1,43 @@ +events { + worker_connections 1024; +} + +http { + # Rate limiting: 40 requests per minute per IP on POST /api/urls + limit_req_zone $binary_remote_addr zone=create_url:10m rate=40r/m; + + server { + listen 80; + server_name go.buffden.com; + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Block direct access to actuator from outside — ALB health check uses /actuator/health + # but only via internal path; external requests are blocked here + location /actuator/ { + return 403; + } + + # Rate-limited URL creation endpoint + location = /api/urls { + limit_req zone=create_url burst=10 nodelay; + proxy_pass http://app:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # All other requests (short URL redirects, health) + location / { + proxy_pass http://app:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} From 9e33547422aadd42db9a85720a8d1e6852694d95 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 13:57:32 -0500 Subject: [PATCH 06/10] update phase B doc for deployment --- docs/deployment/PHASE_B_SECRETS_AND_CONFIG.md | 56 +++++++++++++++---- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/deployment/PHASE_B_SECRETS_AND_CONFIG.md b/docs/deployment/PHASE_B_SECRETS_AND_CONFIG.md index 0129549..bd49936 100644 --- a/docs/deployment/PHASE_B_SECRETS_AND_CONFIG.md +++ b/docs/deployment/PHASE_B_SECRETS_AND_CONFIG.md @@ -24,13 +24,15 @@ Go to **AWS Systems Manager → Parameter Store → Create parameter** for each: | Name | Type | Value | |---|---|---| -| `/tinyurl/prod/db/url` | String | `jdbc:postgresql://:5432/tinyurl` | -| `/tinyurl/prod/db/username` | String | `tinyurl` | -| `/tinyurl/prod/db/password` | SecureString | `` | -| `/tinyurl/prod/base-url` | String | `https://go.buffden.com` | +| `/tinyurl/prod/spring/datasource/username` | String | `tinyurl` | +| `/tinyurl/prod/spring/datasource/password` | SecureString | `` | +| `/tinyurl/prod/tinyurl/base-url` | String | `https://go.buffden.com` | -> Replace `` with the endpoint from Phase A Step 6 (e.g. `tinyurl-prod.xyz.us-east-1.rds.amazonaws.com`). > `SecureString` encrypts the password using KMS — it will not appear in plaintext in the console. +> +> **Why these path names?** Spring Cloud AWS strips the `/tinyurl/prod/` prefix and converts `/` to `.` in the remaining path. So `/tinyurl/prod/spring/datasource/username` maps to `spring.datasource.username`. Wrong path names mean the app silently uses defaults and fails to connect to RDS. +> +> **No SSM param for the DB URL** — the URL is constructed dynamically at deploy time using `RDS_ENDPOINT` and passed as a docker-compose env var (see Step 4). --- @@ -132,6 +134,7 @@ Create this file at the root of the backend repo (same level as `docker-compose. services: nginx: image: nginx:1.27-alpine + container_name: tinyurl-nginx ports: - "80:80" volumes: @@ -149,11 +152,14 @@ services: app: image: ghcr.io/buffden/tinyurl-api:${IMAGE_TAG} + container_name: tinyurl-app environment: SPRING_PROFILES_ACTIVE: prod - SPRING_DATASOURCE_URL: jdbc:postgresql://${RDS_ENDPOINT}:5432/tinyurl + SPRING_DATASOURCE_URL: jdbc:postgresql://${RDS_ENDPOINT}:5432/tinyurl_production_db + expose: + - "8080" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health || exit 1"] interval: 30s timeout: 5s retries: 3 @@ -255,12 +261,12 @@ This tells Spring Boot to load all parameters under `/tinyurl/prod/` from SSM at | SSM path | Spring property | |---|---| -| `/tinyurl/prod/db/url` | `spring.datasource.url` | -| `/tinyurl/prod/db/username` | `spring.datasource.username` | -| `/tinyurl/prod/db/password` | `spring.datasource.password` | -| `/tinyurl/prod/base-url` | `tinyurl.base-url` | +| `/tinyurl/prod/spring/datasource/username` | `spring.datasource.username` | +| `/tinyurl/prod/spring/datasource/password` | `spring.datasource.password` | +| `/tinyurl/prod/tinyurl/base-url` | `tinyurl.base-url` | -> If the SSM paths or Spring property names don't match, the app will fail to start in production. Double-check these before Phase C. +> The datasource URL is NOT loaded from SSM — it is constructed at deploy time from `RDS_ENDPOINT` and passed as `SPRING_DATASOURCE_URL` env var via docker-compose. +> If the SSM paths or Spring property names don't match, the app will silently use defaults and fail to connect to RDS. --- @@ -296,4 +302,30 @@ grep apiUrl src/environments/environment.prod.ts # Expected: https://go.buffden.com/api ``` +--- + +## How it all connects at runtime + +```text +Browser + │ + ▼ +Route 53 (go.buffden.com) + │ + ▼ +ALB (HTTPS:443 → HTTP:80 to EC2) + │ + ▼ +Nginx container (port 80) + ├── rate limits POST /api/urls + ├── blocks /actuator/ + └── proxies everything else + │ + ▼ + Spring Boot container (port 8080, internal only) + ├── reads DB credentials from SSM (via application-prod.yaml import) + ├── reads DB URL from SPRING_DATASOURCE_URL env var (via docker-compose) + └── connects to RDS PostgreSQL +``` + **Proceed to [Phase C](PHASE_C_FIRST_MANUAL_DEPLOY.md).** From 4a5eebb93993fa44edea00a25c99e07ecc1f2cf4 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 17:04:06 -0500 Subject: [PATCH 07/10] Added location = /actuator/health exact-match before the blanket /actuator/ block so ALB health checks pass --- infra/nginx/nginx.prod.conf | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/infra/nginx/nginx.prod.conf b/infra/nginx/nginx.prod.conf index 85b1483..66d78ae 100644 --- a/infra/nginx/nginx.prod.conf +++ b/infra/nginx/nginx.prod.conf @@ -15,8 +15,13 @@ http { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - # Block direct access to actuator from outside — ALB health check uses /actuator/health - # but only via internal path; external requests are blocked here + # Allow ALB health check through to the app + location = /actuator/health { + proxy_pass http://app:8080; + proxy_set_header Host $host; + } + + # Block all other actuator endpoints from external access location /actuator/ { return 403; } From c63ea222a5737ae35838dd51f5c548189317bfda Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 17:04:17 -0500 Subject: [PATCH 08/10] =?UTF-8?q?CORS=20allowed-origins=20from=20YAML=20li?= =?UTF-8?q?st=20=E2=86=92=20comma-separated=20string=20(fixes=20@Value=20i?= =?UTF-8?q?njection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tinyurl/src/main/resources/application-prod.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tinyurl/src/main/resources/application-prod.yaml b/tinyurl/src/main/resources/application-prod.yaml index 1899b6b..4a400aa 100644 --- a/tinyurl/src/main/resources/application-prod.yaml +++ b/tinyurl/src/main/resources/application-prod.yaml @@ -12,5 +12,4 @@ management: tinyurl: cors: - allowed-origins: - - "https://tinyurl.buffden.com" + allowed-origins: "https://tinyurl.buffden.com" From 90f52793c1e42c1f364b0007f1b09b3708a1e1fa Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 17:04:49 -0500 Subject: [PATCH 09/10] Same CORS fix for dev/local lint fix --- tinyurl/src/main/resources/application.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tinyurl/src/main/resources/application.yaml b/tinyurl/src/main/resources/application.yaml index 7c32ad7..8d6dc74 100644 --- a/tinyurl/src/main/resources/application.yaml +++ b/tinyurl/src/main/resources/application.yaml @@ -45,5 +45,4 @@ tinyurl: default-expiry-days: ${TINYURL_DEFAULT_EXPIRY_DAYS:180} short-code-min-length: ${TINYURL_SHORT_CODE_MIN_LENGTH:6} cors: - allowed-origins: - - "http://localhost:4200" + allowed-origins: "http://localhost:4200" From e74d7015ad65001e42dea75620286a3a296100b2 Mon Sep 17 00:00:00 2001 From: Buffden Date: Thu, 26 Mar 2026 17:05:10 -0500 Subject: [PATCH 10/10] Add all issues + fixes encountered during manuall deployment --- .../deployment/PHASE_C_FIRST_MANUAL_DEPLOY.md | 123 ++++++++++++++++-- 1 file changed, 111 insertions(+), 12 deletions(-) diff --git a/docs/deployment/PHASE_C_FIRST_MANUAL_DEPLOY.md b/docs/deployment/PHASE_C_FIRST_MANUAL_DEPLOY.md index bff2257..f91b432 100644 --- a/docs/deployment/PHASE_C_FIRST_MANUAL_DEPLOY.md +++ b/docs/deployment/PHASE_C_FIRST_MANUAL_DEPLOY.md @@ -26,16 +26,16 @@ Run from the root of the backend repo on your local machine. # Authenticate to GitHub Container Registry echo $GITHUB_TOKEN | docker login ghcr.io -u buffden --password-stdin -# Build the production image +# Build the production image — IMPORTANT: specify linux/amd64 platform +# EC2 is x86_64. If you build on Apple Silicon (M1/M2/M3) without this flag, +# the image will be arm64 and fail to start on EC2 with "no matching manifest" error. ./gradlew bootJar -docker build -t ghcr.io/buffden/tinyurl-api:v1.0.0 . - -# Push to GHCR -docker push ghcr.io/buffden/tinyurl-api:v1.0.0 +docker buildx build --platform linux/amd64 -t ghcr.io/buffden/tinyurl-api:v1.0.0 --push . ``` > `GITHUB_TOKEN` — generate a Personal Access Token (PAT) at GitHub → Settings → Developer settings → Personal access tokens. Needs `write:packages` scope. > After this, the image is at `ghcr.io/buffden/tinyurl-api:v1.0.0` and is private by default. +> Note: `docker buildx build --push` builds and pushes in one step. No separate `docker push` needed. **Make the package visible to EC2:** @@ -63,6 +63,9 @@ In the terminal: # Switch to ubuntu user sudo su - ubuntu +# Fix /app ownership if needed (user data script runs as root, so ubuntu can't write) +sudo chown -R ubuntu:ubuntu /app + # Create app directory mkdir -p /app/infra/nginx @@ -81,19 +84,27 @@ echo 'export RDS_ENDPOINT=tinyurl-prod.xyz.us-east-1.rds.amazonaws.com' >> ~/.ba source ~/.bashrc ``` -**Option B — Use AWS S3 as a transfer mechanism:** +**Option B — Use AWS S3 as a transfer mechanism (recommended):** + +SSM browser terminal mangles multi-line heredoc pastes (characters get dropped/reordered), making Option A unreliable for large files. Use S3 instead. ```bash -# Local machine: upload files to S3 +# Local machine: upload files to S3 (run from repo root) aws s3 cp docker-compose.prod.yml s3://tinyurl-spa-prod/deploy/docker-compose.prod.yml aws s3 cp infra/nginx/nginx.prod.conf s3://tinyurl-spa-prod/deploy/nginx.prod.conf # On EC2 (via SSM Session Manager): -aws s3 cp s3://tinyurl-spa-prod/deploy/docker-compose.prod.yml /app/docker-compose.prod.yml +# Install AWS CLI if not present +sudo apt-get install -y awscli + mkdir -p /app/infra/nginx +aws s3 cp s3://tinyurl-spa-prod/deploy/docker-compose.prod.yml /app/docker-compose.prod.yml aws s3 cp s3://tinyurl-spa-prod/deploy/nginx.prod.conf /app/infra/nginx/nginx.prod.conf ``` +> If `aws s3 cp` returns 403 Forbidden, the EC2 IAM role is missing S3 permissions. +> Fix: AWS Console → IAM → Roles → `role-tinyurl-ec2` → Attach `AmazonS3ReadOnlyAccess`. + --- ## Step 3 — Start Application on EC2 @@ -101,6 +112,12 @@ aws s3 cp s3://tinyurl-spa-prod/deploy/nginx.prod.conf /app/infra/nginx/nginx.pr In the SSM Session Manager terminal on EC2: ```bash +# Install Docker if user data script didn't run (check with: docker --version) +sudo apt-get update +sudo apt-get install -y docker.io docker-compose-v2 +sudo usermod -aG docker ubuntu +newgrp docker # apply group without logout + cd /app # Set image tag @@ -121,6 +138,32 @@ Watch for these log lines indicating successful startup: - `Flyway migrations completed` - No `ERROR` or `FATAL` lines +**If the app crashes with `Could not resolve placeholder 'tinyurl.cors.allowed-origins'`:** + +`@Value` cannot inject a YAML list property. The `application-prod.yaml` CORS config must use a comma-separated string, not YAML list syntax: + +```yaml +# WRONG — causes placeholder resolution failure +tinyurl: + cors: + allowed-origins: + - "https://tinyurl.buffden.com" + +# CORRECT +tinyurl: + cors: + allowed-origins: "https://tinyurl.buffden.com" +``` + +**If Docker logs show `AccessDeniedException` for CloudWatch Logs:** + +The EC2 role is missing CloudWatch permissions. Fix: AWS Console → IAM → Roles → `role-tinyurl-ec2` → Attach `CloudWatchLogsFullAccess`. Then restart: + +```bash +docker compose -f docker-compose.prod.yml down +docker compose -f docker-compose.prod.yml up -d +``` + If you see SSM Parameter Store errors, check: - Parameters exist with correct names in `/tinyurl/prod/` - EC2 IAM role has SSM read permission @@ -135,11 +178,38 @@ If you see SSM Parameter Store errors, check: 3. Wait for the registered EC2 to show **Healthy** status (can take up to 60s) If it stays **Unhealthy:** -- SSH alternative: use SSM Session Manager to check `curl http://localhost:8080/actuator/health` -- Check Docker logs: `docker compose -f docker-compose.prod.yml logs app` + +- Use SSM Session Manager to check: `curl http://localhost/actuator/health` + - If this returns `{"status":"UP"}` the app is fine — the issue is between Nginx and the health check path + - If this returns 403, Nginx is blocking `/actuator/health` (see fix below) + - If connection refused, the app hasn't started — check `docker compose -f docker-compose.prod.yml logs app` - Confirm Nginx is running: `docker compose -f docker-compose.prod.yml ps` - Confirm security group `sg-ec2` allows port 80 from `sg-alb` +**Critical: ALB targets port 80 on EC2, which goes through Nginx — not directly to the app.** + +If Nginx has a blanket `location /actuator/ { return 403; }` block, health checks will return 403 and the target will stay Unhealthy. The fix is an exact-match location *before* the blanket block in `nginx.prod.conf`: + +```nginx +# Allow ALB health checks through — MUST be before the blanket /actuator/ block +location = /actuator/health { + proxy_pass http://app:8080; + proxy_set_header Host $host; +} + +# Block all other actuator endpoints +location /actuator/ { + return 403; +} +``` + +After updating the nginx config, re-upload via S3 and restart the nginx container: + +```bash +aws s3 cp s3://tinyurl-spa-prod/deploy/nginx.prod.conf /app/infra/nginx/nginx.prod.conf +docker compose -f docker-compose.prod.yml restart nginx +``` + --- ## Step 5 — Build and Upload Angular to S3 @@ -166,6 +236,29 @@ aws s3 ls s3://tinyurl-spa-prod/ > `--delete` removes any stale files from previous builds. > `dist/browser/` is the Angular 19 output path — verify with your actual build output. +**If `ng build` fails with `NG0401` during route extraction:** + +This is an Angular 19.2 SSR breaking change. The `main.server.ts` bootstrap function must accept and forward `BootstrapContext`: + +```typescript +// src/main.server.ts — CORRECT for Angular 19.2+ +import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context); + +export default bootstrap; +``` + +Without the `BootstrapContext` parameter, Angular's server platform cannot initialize and throws NG0401 for every prerender attempt. + +**Additional Angular 19 SSR requirements** (all must be in place): + +- `app.config.ts` must include `provideAnimationsAsync()` — Angular Material components (`MatTabsModule`, `MatExpansionModule`) require the `ANIMATION_MODULE_TYPE` token to be provided +- `app.config.server.ts` must include `provideServerRoutesConfig(serverRoutes)` and `provideNoopAnimations()` +- `app.routes.server.ts` must exist and define render modes using `RenderMode` from `@angular/ssr` — the old `data: { prerender: true }` route property is deprecated and causes NG0401 + --- ## Step 6 — Invalidate CloudFront Cache @@ -228,8 +321,14 @@ curl -I https://tinyurl.buffden.com | Symptom | Likely cause | Fix | |---|---|---| -| ALB health check unhealthy | App not started or crash on boot | Check `docker compose logs app` via SSM | -| CORS error in browser | `CorsConfig.java` not applied | Verify `application-prod.yaml` has correct origin | +| `no matching manifest for linux/amd64` on EC2 | Image built on Apple Silicon without platform flag | Rebuild with `docker buildx build --platform linux/amd64` | +| `docker: command not found` on EC2 | User data script didn't run | `sudo apt-get install -y docker.io docker-compose-v2` | +| `aws s3 cp` returns 403 Forbidden | EC2 role missing S3 permissions | Attach `AmazonS3ReadOnlyAccess` to `role-tinyurl-ec2` | +| CloudWatch `AccessDeniedException` in Docker logs | EC2 role missing CloudWatch permissions | Attach `CloudWatchLogsFullAccess` to `role-tinyurl-ec2` | +| App crash: `Could not resolve placeholder 'tinyurl.cors.allowed-origins'` | CORS config uses YAML list syntax; `@Value` requires a string | Change `allowed-origins` to comma-separated string in `application-prod.yaml` | +| ALB health check stays Unhealthy with 403 | Nginx blocks `/actuator/` before ALB can reach app | Add `location = /actuator/health` exact-match block before the blanket `location /actuator/` block in nginx config | +| `ng build` fails with NG0401 | `main.server.ts` missing `BootstrapContext` parameter (Angular 19.2+) | Update bootstrap function signature — see Step 5 | +| CORS error in browser | `CorsConfig.java` not applied | Verify `application-prod.yaml` has correct origin as comma-separated string | | Short URL has wrong domain | Wrong SSM `base-url` | Update `/tinyurl/prod/base-url` → `https://go.buffden.com` | | Angular can't reach API | Wrong `environment.prod.ts` | Verify `apiUrl: 'https://go.buffden.com/api'` | | S3 returns 403 on SPA routes | Missing CloudFront error page config | Add 403 → `/index.html` error page in CloudFront |