Goal: All AWS resources exist and are wired together. No application deployed yet.
Method: Manual AWS Console (Terraform deferred to v2)
Region: us-east-1 (N. Virginia) for everything
Estimated time: 2–3 hours in the console
Deliverable: curl https://go.buffden.com returns a connection response — EC2 is up and reachable, but no app running yet. That confirms the full network path is working.
- Step 1 — Route 53 hosted zone + DNS delegation
- Step 2 — ACM wildcard certificate
- Step 3 — VPC and networking
- Step 4 — Security groups
- Step 5 — IAM roles
- Step 6 — RDS PostgreSQL
- Step 7 — EC2 instance
- Step 8 — S3 bucket
- Step 9 — CloudFront distribution
- Step 10 — Route 53 DNS records
- Step 11 — Cloudflare DNS migration (free DDoS + edge protection)
- Go to Route 53 → Hosted zones → Create hosted zone
- Domain name:
buffden.com - Type: Public hosted zone
- Click Create hosted zone
- Copy the 4 NS records shown (e.g.
ns-123.awsdns-45.com, etc.)
- Log in to Namecheap → Domain List →
buffden.com→ Manage - Go to Nameservers section
- Select Custom DNS
- Paste the 4 Route 53 NS records (one per field)
- Save
Propagation takes up to 48 hours but is usually done in under 1 hour. Verify with:
nslookup -type=NS buffden.com 8.8.8.8You should see your Route 53 nameservers in the response.
A single wildcard cert covers both tinyurl.buffden.com and go.buffden.com.
- Go to ACM (Certificate Manager) — confirm you are in us-east-1
- Click Request certificate → Request a public certificate
- Domain names:
*.buffden.com - Validation method: DNS validation
- Click Request
- On the certificate detail page, click Create records in Route 53 (one click — auto-adds the CNAME validation record)
- Wait ~2 minutes for status to change from Pending validation to Issued
ACM certificates auto-renew — you never need to touch this again.
- Go to VPC → Your VPCs → Create VPC
- Name:
tinyurl-prod-vpc - IPv4 CIDR:
10.0.0.0/16 - Click Create VPC
Public subnets (EC2):
| Name | AZ | CIDR |
|---|---|---|
tinyurl-public-1a |
us-east-1a | 10.0.1.0/24 |
tinyurl-public-1b |
us-east-1b | 10.0.2.0/24 |
Private subnets (RDS):
| Name | AZ | CIDR |
|---|---|---|
tinyurl-private-1a |
us-east-1a | 10.0.3.0/24 |
tinyurl-private-1b |
us-east-1b | 10.0.4.0/24 |
For each subnet:
- Go to VPC → Subnets → Create subnet
- Select
tinyurl-prod-vpc - Fill in name, AZ, CIDR as above
Two public subnets across AZs are created for future flexibility, though only one EC2 is used.
- Go to VPC → Internet Gateways → Create internet gateway
- Name:
tinyurl-igw - Click Create, then Actions → Attach to VPC → tinyurl-prod-vpc
- Go to VPC → Route Tables → Create route table
- Name:
tinyurl-public-rt, VPC:tinyurl-prod-vpc - After creating, click Edit routes → Add route:
- Destination:
0.0.0.0/0 - Target:
tinyurl-igw(Internet Gateway)
- Destination:
- Click Subnet associations → Edit → associate both public subnets
Private subnets use the default route table (no internet route) — RDS stays unreachable from outside.
Create two security groups inside tinyurl-prod-vpc.
AWS does not allow security group names starting with
sg-— use names without that prefix.
- VPC → Security Groups → Create security group
- Name:
tinyurl-ec2, VPC:tinyurl-prod-vpc - Description:
Application server, accepts HTTP and HTTPS from internet - Inbound rules:
- HTTP (80) from
0.0.0.0/0(for HTTP → HTTPS redirect) - HTTPS (443) from
0.0.0.0/0
- HTTP (80) from
- Outbound: All traffic (default)
No port 22. SSH is not used. EC2 access is via SSM Session Manager only.
- Name:
tinyurl-rds, VPC:tinyurl-prod-vpc - Description:
Database, accepts traffic from EC2 only - Inbound rules:
- PostgreSQL (5432) from source:
tinyurl-ec2
- PostgreSQL (5432) from source:
- Outbound: None (remove default rule)
This role lets EC2 use SSM Session Manager (replaces SSH) and read secrets from SSM Parameter Store.
- Go to IAM → Roles → Create role
- Trusted entity: AWS service → EC2
- Attach policy:
AmazonSSMManagedInstanceCore(AWS managed — enables SSM) - Name:
role-tinyurl-ec2 - After creating, add an inline policy named
tinyurl-ssm-params:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": "arn:aws:ssm:us-east-1:*:parameter/tinyurl/prod/*"
}
]
}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.comis already listed (likely from a previous project). If it is, proceed directly to 2b.
- Go to IAM → Identity providers → Add provider
- Provider type: OpenID Connect
- Provider URL:
https://token.actions.githubusercontent.com - Click Get thumbprint
- Audience:
sts.amazonaws.com - Click Add provider
2b. Create the role:
- IAM → Roles → Create role
- Trusted entity: Web identity
- Identity provider:
token.actions.githubusercontent.com - Audience:
sts.amazonaws.com - Name:
role-github-actions-tinyurl - Attach these AWS managed policies:
AmazonS3FullAccess(scoped to tinyurl bucket — or use inline policy below)CloudFrontFullAccessAmazonSSMFullAccess(for RunCommand to EC2)
- After creating, edit the trust policy to scope it to your repos only:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:Buffden/tinyurl-api:*",
"repo:Buffden/tinyurl-gui:*"
]
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
]
}Replace
<account-id>with your 12-digit AWS account ID. The*wildcard allows any branch and trigger type (push, workflow_dispatch, etc.) for both repos. If you want to lock down tomainonly after testing, replace*withref:refs/heads/main.
- Go to RDS → Subnet groups → Create DB subnet group
- Name:
tinyurl-rds-subnet-group - VPC:
tinyurl-prod-vpc - Add subnets: both private subnets (
10.0.3.0/24and10.0.4.0/24)
- Go to RDS → Databases → Create database
- Engine: PostgreSQL, version: 17.x (latest 17)
- 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)
- DB instance identifier:
tinyurl-prod - Master username:
tinyurl - Master password: Self managed — generate with
openssl rand -base64 32, save it, you will put it in SSM Parameter Store in Phase B - Database authentication: Password authentication
- Instance class:
db.t3.micro(selected automatically by Free Tier template) - Multi-AZ: Disable (not needed for this project, saves ~$13/month)
- Storage: 25 GiB gp3, disable auto-scaling (minimum enforced by AWS is 20 GiB)
- Connectivity: Don't connect to an EC2 compute resource (EC2 not created yet — connectivity handled via security groups)
- Network type: IPv4
- VPC:
tinyurl-prod-vpc - DB subnet group:
tinyurl-rds-subnet-group - Public access: No
- VPC security group:
tinyurl-rds - Initial database name:
tinyurl_production_db - Performance Insights: Enable, retention 7 days (free tier) — or disable entirely
- Certificate authority:
rds-ca-rsa2048-g1(default — free, enables TLS) - Automated backups: Enabled, 7-day retention
- Encryption: Enabled (AWS managed key)
- Deletion protection: Enabled
- 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.
- Go to EC2 → Launch instance
- Name:
tinyurl-prod - AMI: Ubuntu Server 22.04 LTS (64-bit x86)
- Instance type:
t3.small(~$15/month — 2 GB RAM needed to run Docker + Spring Boot + Nginx without OOM) - Key pair: Proceed without a key pair (access is via SSM — no SSH needed)
- Network settings:
- VPC:
tinyurl-prod-vpc - Subnet:
tinyurl-public-1a(10.0.1.0/24) — must be a public subnet, not private - Auto-assign public IP: Enable
- Security group:
tinyurl-ec2
- VPC:
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.
- Storage: 20 GB gp3
- Advanced details → IAM instance profile:
role-tinyurl-ec2 - User data (paste this — installs Docker on first boot):
#!/bin/bash
apt-get update -y
apt-get install -y docker.io docker-compose-plugin
systemctl enable docker
systemctl start docker
usermod -aG docker ubuntu
mkdir -p /app- 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
SendCommandin Phase D to trigger deployments on the EC2 instance. Without SSM working, automated deployments will not work.
- Go to S3 → Create bucket
- Bucket name:
tinyurl-spa-prod - Region:
us-east-1 - Bucket type: General purpose
- Object ownership: ACLs disabled (recommended)
- Block all public access: On (all four checkboxes)
- Versioning: Disabled
- Encryption: SSE-S3 (default), Bucket Key: Enable
- 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.
- Go to CloudFront → Create distribution
- 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)
- Distribution name:
tinyurl-spa-prod - Distribution type: Single website or app
- Route 53 managed domain: leave blank
- Origin type: Amazon S3
- S3 origin:
tinyurl-spa-prod.s3.us-east-1.amazonaws.com - Origin path: leave blank
- Allow private S3 bucket access: Allow private S3 bucket access to CloudFront (Recommended) — CloudFront will automatically update the S3 bucket policy
- Origin settings: Use recommended origin settings
- Cache settings: Use recommended cache settings tailored to serving S3 content
- 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.
- 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
- Alternate domain names:
Add custom error pages (required for Angular routing):
- CloudFront → your distribution → Error pages tab → Create custom error response
- HTTP error code: 403
- Customize error response: Yes
- Response page path:
/index.html - HTTP response code: 200
- Click Create
- 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.
- Go to Route 53 → Hosted zones → buffden.com → Create record
Record 1 — Angular SPA:
- Record name:
tinyurl - Record type: A
- Alias: Yes
- Route traffic to: Alias to CloudFront distribution
- Select your CloudFront distribution
- Click Create record
Record 2 — API + redirects:
- Record name:
go - Record type: A
- Alias: No
- Value: EC2 Elastic IP address (e.g.
100.56.217.158) - Click Create record
Moves DNS from Route 53 to Cloudflare so flood attacks are absorbed at Cloudflare's edge before CloudFront or EC2 ever see the traffic — eliminating the billing impact of DDoS attacks. All AWS services stay completely unchanged.
- Go to cloudflare.com → Sign up → Free plan
- Click Add a Site →
buffden.com→ Free plan - Select Import DNS records automatically — Cloudflare scans Route 53 records
Cloudflare's auto-import is incomplete. Manually verify and correct all records:
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | go |
<EC2 Elastic IP> |
Orange (Proxied) |
| CNAME | tinyurl |
d1anlbbmfo4elu.cloudfront.net |
Orange (Proxied) |
| A | ems |
<ems-public-ip> |
Orange (Proxied) |
| CNAME | portfolio |
buffden.github.io |
Grey (DNS only) |
| CNAME | _2a3ec5a40d53220a744f6e248e46f22b |
_bf955b229028da621bc467ed086b9ddc.jkddzztszm.acm-validations.aws |
Grey (DNS only) |
The
gorecord should be an A record pointing to the EC2 Elastic IP. The EIP does not change on stop/start.The ACM validation CNAME (
_2a3ec5a...) must be grey cloud (DNS only). If proxied, AWS cannot reach it to auto-renew your SSL certificate.
Only do this after 12b is complete and all records are verified.
- Log in to namecheap.com → Domain List → Manage → buffden.com
- Under Nameservers → Custom DNS
- Replace all 4
awsdnsnameservers with the 2 Cloudflare nameservers shown in your dashboard - Save — propagation takes 5–30 minutes
Rollback: restore the 4 Route 53 nameservers at Namecheap at any time. The Route 53 hosted zone is never deleted.
SSL/TLS → Overview: set mode to Full
Do not use Flexible — CloudFront enforces HTTPS and Flexible causes an infinite redirect loop.
SSL/TLS → Edge Certificates:
- Always Use HTTPS → On
- Minimum TLS Version → TLS 1.2
Security → Settings:
- Security Level → Medium
- Browser Integrity Check → On
Security → WAF → Rate limiting rules → Create rule:
Rule name: Protect URL creation endpoint
Field: URI Path equals /api/urls
Characteristics: IP address
Rate: 20 requests per 10 seconds
Action: Block
Duration: 10 seconds (free plan maximum)
Free plan allows 1 rate limiting rule. The SPA (
tinyurl.buffden.com) does not need one — it is served from CloudFront edge cache and flood traffic never reaches S3 or EC2.
# Nameservers should show Cloudflare
nslookup -type=NS buffden.com
# cf-ray header confirms traffic flows through Cloudflare
curl -I https://go.buffden.com/actuator/health
# Look for: cf-ray: xxxxxxxx-XXX| Symptom | Cause | Fix |
|---|---|---|
Redirect loop on tinyurl.buffden.com |
SSL mode set to Flexible | Change SSL mode to Full |
| ACM certificate fails to renew | ACM validation CNAME is proxied | Set _2a3ec5a... record to grey cloud |
cf-ray header missing |
Propagation pending or record is grey cloud | Wait 30 min; verify orange cloud on go and tinyurl records |
| Site unreachable after nameserver switch | Record missing in Cloudflare | Restore Route 53 nameservers at Namecheap; add missing record; switch again |
If you detect an active flood: Cloudflare dashboard → your domain → Quick Actions → Enable Under Attack Mode. Disables automatically when toggled off.
Once Cloudflare has been running without issues for 24 hours, delete the Route 53 hosted zone to stop the $0.50/month fee. Cloudflare is now the authoritative DNS.
After all steps above, verify the network path is working:
# Should return a TLS handshake or connection response (EC2 up, no app running yet — expected)
curl -I https://go.buffden.com
# Should return HTTP 301 redirect to https://
curl -I http://go.buffden.com
# Should return HTTP 301 or 403 (CloudFront up, no files in S3 yet — expected)
curl -I https://tinyurl.buffden.comIf you get 502 from go.buffden.com and a response from tinyurl.buffden.com, Phase A is complete.
Proceed to Phase B.