AWS Deploy Skill
Deploy a web project to a single EC2 instance using Docker + Caddy, with optional custom domain, automatic HTTPS, and GitHub Actions CI/CD.
Philosophy
This skill prioritizes simplicity over scalability. One server, one Docker Compose stack, zero managed services. This covers 90% of personal projects, MVPs, and small business apps. The user can always scale later.
Skip-if-done principle: At the start of EVERY phase, check whether the user has already completed it. Don't force someone to set up an AWS account if they already have one. Ask first, verify if possible, then skip or proceed.
When to Use
- •User has a web project (frontend, backend, fullstack, API) and wants it live on the internet
- •User mentions AWS, EC2, deployment, hosting, or "making it live"
- •User wants to update a previously deployed project
Workflow Overview
The deployment has 8 phases. Walk the user through each one sequentially.
Phase 1: Pre-flight Check → Verify project is ready (git, .gitignore, .env, etc.) Phase 2: Detect & Containerize → Analyze project, generate Docker files Phase 2.5: Docker Validation → Validate Docker config before deploying (NEW!) Phase 3: AWS Setup → Ensure CLI configured, provision EC2 + security groups Phase 4: Deploy → SSH into EC2, clone repo, docker compose up Phase 5: Domain + HTTPS → Optional custom domain, Caddy auto-HTTPS Phase 6: CI/CD → GitHub Actions workflow for push-to-deploy Phase 7: Verify → Health check, hand over URL + SSH details
Phase 1: Pre-flight Check
Goal: Make sure the project is clean, version-controlled, and ready to be deployed.
Read references/preflight.md for detailed checks and auto-fix patterns.
Ask First
Before running any checks, ask the user:
- •"Is your project in a GitHub repo already?"
- •"Do you have a
.gitignoreand.envset up?"
If they confirm everything is good, do a quick verify and move on. Don't lecture them.
Checks to Run
- •
Git initialized?
- •Look for
.git/directory - •If not:
git init, help them create a GitHub repo
- •Look for
- •
GitHub remote configured?
- •
git remote -v— does it have an origin? - •If not: walk them through creating a repo on GitHub and adding the remote
- •If the repo is private, note this for Phase 4 (deploy keys needed)
- •
- •
.gitignoreexists and is correct?- •If missing: generate one for their stack (Node, Python, etc.)
- •If exists: verify it includes critical entries:
- •
.env(MUST be present — secrets should never be committed) - •
node_modules/(Node projects) - •
__pycache__/,*.pyc,venv/(Python projects) - •
.DS_Store(macOS) - •
dist/,build/(build outputs — optional, depends on workflow)
- •
- •
.envfile handling?- •If
.envexists but is NOT in.gitignore→ add it immediately and warn the user - •If
.envexists and IS in.gitignore→ good - •If no
.envexists but the app uses environment variables → create a.env.examplewith placeholder values (no real secrets) for documentation - •Check git history: has
.envbeen committed before? If so, warn the user that secrets may be in git history and suggest they rotate any exposed keys
- •If
- •
No secrets in the codebase?
- •Quick grep for common patterns: API keys, passwords, AWS credentials hardcoded in source files
- •If found: flag them and suggest moving to
.env
- •
Code pushed to GitHub?
- •
git status— are there uncommitted changes? - •
git log --oneline -1— has anything been committed? - •Help them commit and push if needed
- •
Output
After pre-flight, confirm:
✅ Git repo initialized with GitHub remote ✅ .gitignore configured for [stack] ✅ .env secured (in .gitignore, not in git history) ✅ Code pushed to GitHub
Phase 2: Detect & Containerize
Goal: Generate Docker files for the project, then verify it's actually ready for production.
This phase has two parts: containerization and a deployment readiness check.
Read references/dockerize.md for stack-specific Dockerfile templates and patterns.
Read references/readiness-check.md for the full readiness scan and auto-fix patterns.
Ask First
- •"Do you already have a Dockerfile?" → If yes, review it and use it
- •"Does your app need a database?" → If yes, include Postgres in the compose file
Part A: Containerize
- •
Detect the stack by examining the project files:
- •
package.json→ Node.js (check fornext,react,vue,express, etc.) - •
requirements.txt/pyproject.toml→ Python (check forflask,django,fastapi, etc.) - •
go.mod→ Go - •
Cargo.toml→ Rust - •
index.html(no framework) → Static site - •If the user already has a
Dockerfile, use it. Ask if they want it reviewed.
- •
- •
Generate the Dockerfile using the appropriate template from
references/dockerize.md. Use multi-stage builds where applicable to keep images small. - •
Generate
docker-compose.ymlwith:- •The app service (built from Dockerfile)
- •Caddy reverse proxy service
- •Shared network
- •Optional: Postgres service if the app needs a database
- •Volume for Caddy data (certificate storage)
- •Volume for Caddy config
- •
Generate
Caddyfile— start with the IP-only version:code:80 { reverse_proxy app:3000 }This gets upgraded to the domain version in Phase 5 if the user has a domain.
- •
Add Docker-related entries to
.gitignoreif not already present:code# Docker docker-compose.override.yml
- •
Create
.dockerignoreto keep images small:codenode_modules .git .env *.md .DS_Store
Adjust based on the stack.
Part B: Deployment Readiness Check
Before moving to AWS setup, scan the codebase to catch issues that would break in production. This saves the user from deploying a broken app and debugging over SSH.
Read references/readiness-check.md for the full scan patterns and auto-fix templates.
Code-Level Checks
- •Hardcoded localhost / local URLs — scan source files for
localhost,127.0.0.1,0.0.0.0in fetch calls, API URLs, WebSocket connections, CORS origins. These must be replaced with environment variables. - •Hardcoded ports — API calls pointing to specific ports like
:3000,:5000,:8080that won't resolve in production. - •Debug mode left on — Django
DEBUG=True, Flaskdebug=True,console.logspam, verbose error pages.
Config-Level Checks
- •Missing production dependencies —
gunicornnot inrequirements.txt, nostartscript inpackage.json, missinguvicornfor FastAPI. - •Framework-specific production config — Next.js missing
output: 'standalone', Django missingALLOWED_HOSTS, Flask missing production WSGI server. - •Environment variable coverage — are all env vars used in code documented in
.env.example?
Docker-Level Checks
- •Port consistency — the port exposed in Dockerfile matches the port in Caddyfile's
reverse_proxydirective. - •Docker Compose build test — optionally run
docker compose buildto verify the image builds successfully.
Output
Present results clearly and offer to auto-fix:
🔍 Deployment Readiness Check
⚠️ Hardcoded localhost found:
- src/api.js:12 → fetch("http://localhost:3000/api")
- src/config.js:5 → CORS_ORIGIN: "http://localhost:3000"
Fix: Replace with environment variables? (y/n)
⚠️ Missing production dependency:
- gunicorn not in requirements.txt
Fix: Add gunicorn to requirements.txt? (y/n)
✅ No debug mode detected
✅ Ports consistent (app:3000 → Caddy reverse_proxy app:3000)
✅ Environment variables documented in .env.example
✅ Docker build succeeds
Fix 2 issues before continuing to AWS setup?
After all issues are resolved (or skipped), proceed to Phase 3.
Part C: Local Test (Optional)
Ask the user if they want to test locally before deploying:
docker compose up --build
This catches build errors before they're debugging over SSH on EC2.
Phase 2.5: Docker Validation (NEW!)
Goal: Validate Docker configuration to catch build errors BEFORE deploying to EC2.
Read references/docker-validation.md for detailed validation checks and auto-fix patterns.
Why This Phase Exists
Real-world deployment issues we've encountered:
- •✅ Build context mismatch (Dockerfile COPY paths not in context)
- •✅ Missing TypeScript (--only=production skipped devDependencies needed for build)
- •✅ Missing environment variables (config used vars not in docker-compose.yml)
- •✅ Port mismatches (app listening on different port than Caddy proxies to)
These would have been caught with validation!
Validation Checks
Run these checks automatically after generating Docker files:
1. Build Context Validation
- •Parse docker-compose.yml to get each service's build context
- •Parse Dockerfile to find all COPY/ADD commands
- •Verify all COPY source paths exist relative to build context
- •Auto-fix: Suggest using root context (
.) for complex projects
2. Multi-Stage Build Dependencies
- •Check if
npm ci --only=productionis followed bynpm run build - •Check if package.json has build tools in devDependencies (typescript, vite, webpack)
- •Auto-fix: Remove
--only=productionflag if build needs devDependencies
3. Environment Variable Coverage
- •Scan backend config files for env var usage (os.getenv, process.env)
- •Compare with docker-compose.yml environment section
- •Check .env.example for documented variables
- •Auto-fix: Add missing env vars to docker-compose.yml with defaults
4. Port Consistency
- •Find app's listening port (Dockerfile EXPOSE, backend code, docker-compose ports)
- •Find Caddyfile reverse_proxy target port
- •Auto-fix: Flag mismatches, suggest consistent port
5. Docker Compose Syntax
- •Run
docker compose configto validate YAML - •Auto-fix: Show syntax errors and suggest fixes
6. Local Build Test (Optional)
- •Offer to run
docker compose buildlocally - •Let user decide (takes 5-10 min but catches all build errors)
Output Format
Present all findings grouped by severity:
🔍 Docker Configuration Validation
❌ CRITICAL (must fix):
• Build context mismatch in frontend service
- Dockerfile: COPY docker/nginx.conf
- Context: ./frontend (docker/ not accessible)
Fix: Change context to "." in docker-compose.yml? (y/n)
⚠️ WARNINGS (recommended):
• Missing GEMINI_MODEL in docker-compose.yml
- Backend config.py uses os.getenv('GEMINI_MODEL')
- Not defined in environment section
Fix: Add to docker-compose.yml? (y/n)
✅ PASSED:
• Port consistency verified
• Docker Compose syntax valid
• All COPY sources exist
2 critical, 1 warning. Fix now? (y/n)
User Can Skip
If user chooses to skip validation:
⚠️ Skipping Docker validation. If build fails on EC2, you'll need to: 1. Fix the issue locally 2. Commit and push changes 3. SSH to EC2: ssh -i ~/.ssh/key.pem ubuntu@<ip> 4. Rebuild: cd /opt/apps/<name> && git pull && docker compose up -d --build This can take 10-20 minutes to debug remotely. Testing locally takes ~5 min but catches errors immediately. Continue to AWS setup anyway? (y/n)
Benefits
Time saved:
- •Our deployment: ~30 min debugging Docker issues on EC2
- •With validation: ~2 min to catch locally
Better UX:
- •Catch errors on fast local machine, not slow EC2 SSH
- •Understand issues before they're on a live server
- •Learn Docker best practices
Phase 3: AWS Setup
Goal: Ensure the user has a working AWS CLI and a running EC2 instance.
Read references/setup-aws.md for detailed AWS CLI setup instructions and troubleshooting.
Ask First
Ask these questions up front to skip unnecessary steps:
- •"Do you have an AWS account?" → If no, direct them to create one
- •"Do you have the AWS CLI installed?" → If no, walk them through installation
- •"Have you configured the CLI with credentials?" → Verify with
aws sts get-caller-identity - •"Do you already have an EC2 instance you want to use?" → If yes, get the instance ID and skip provisioning
Steps (skip any the user has already done)
- •
Check AWS CLI: Run
aws sts get-caller-identityto verify credentials. If this fails, walk the user through setup (see reference doc). - •
Important security notes (mention these once, don't lecture):
- •They should be using an IAM user, NOT root credentials
- •Root account should have MFA enabled
- •Never commit AWS credentials to git
- •If they're using root: strongly suggest creating an IAM user (see reference doc for steps)
- •
Create a key pair (if the user doesn't have one):
bashaws ec2 create-key-pair \ --key-name deploy-key \ --query 'KeyMaterial' \ --output text > ~/.ssh/deploy-key.pem chmod 400 ~/.ssh/deploy-key.pem
- •
Create a security group:
bashaws ec2 create-security-group \ --group-name web-server-sg \ --description "Security group for web server"
Then open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS).
- •
Launch EC2 instance:
- •AMI: Ubuntu 24.04 LTS (look up current AMI ID for the user's region)
- •Instance type:
t2.micro(free tier) — mentiont3.smallif they need more RAM - •Storage: 20 GB gp3
- •Set
DeleteOnTermination: falseon the EBS volume as a safety net - •Tag with a name for easy identification
- •
Allocate an Elastic IP and associate it with the instance:
bashaws ec2 allocate-address --domain vpc aws ec2 associate-address --instance-id <id> --allocation-id <alloc-id>
- •
Wait for instance to be running, then output:
- •Public IP / Elastic IP
- •SSH command:
ssh -i ~/.ssh/deploy-key.pem ubuntu@<ip>
Phase 4: Deploy
Goal: Get the app running on EC2.
Read references/deploy.md for the full deployment script and troubleshooting.
Steps
- •
SSH into the instance and run the initial setup script:
- •Update packages
- •Install Docker and Docker Compose
- •Install Git
- •Create project directory at
/opt/apps/<project-name>/ - •Add 2 GB swap space (important for t2.micro)
- •
Clone the repo:
bashcd /opt/apps/<project-name> git clone <repo-url> .
If the repo is private, help the user set up a deploy key or personal access token.
- •
Set up environment variables:
- •Ask the user for any required
.envvariables - •Create
/opt/apps/<project-name>/.envon the server - •NEVER commit
.envto the repo — remind the user - •For generating secrets:
openssl rand -hex 32
- •Ask the user for any required
- •
Build and start:
bashdocker compose up -d --build
- •
Verify the app is running:
bashdocker compose ps curl -s http://localhost
Phase 5: Domain + HTTPS
Goal: Set up a custom domain with automatic HTTPS, or confirm HTTP-only access via IP.
Read references/domain-https.md for Caddy configuration patterns and DNS setup.
Ask First
- •"Do you have a custom domain you want to use?" → If no, skip to the end of this phase
- •"Where did you buy your domain?" → Provide registrar-specific DNS instructions
Steps
- •
DNS configuration: Tell the user to create an A record pointing to the EC2 Elastic IP:
codeType: A Name: @ (or subdomain like "app") Value: <elastic-ip> TTL: 300
- •
Update the Caddyfile to use the domain:
codeyour-domain.com { reverse_proxy app:3000 }Caddy automatically provisions a Let's Encrypt certificate.
- •
Restart Caddy:
bashdocker compose restart caddy
- •
No domain? The app is already accessible at
http://<elastic-ip>. Let the user know they can add a domain later by repeating these steps.
Phase 6: CI/CD (Push-to-Deploy)
Goal: Set up GitHub Actions so pushing to main automatically redeploys.
Read references/ci-cd.md for the full workflow file and setup instructions.
Ask First
- •"Do you want automatic deploys when you push to main?" → If no, skip this phase and show them the manual update commands instead
Steps
- •
Generate
.github/workflows/deploy.ymlthat:- •Triggers on push to
main - •SSHes into the EC2 instance
- •Runs
git pull && docker compose up -d --build - •Includes a health check
- •Cleans up old Docker images
- •Triggers on push to
- •
Set up GitHub Secrets — tell the user to add these in their repo settings (Settings > Secrets > Actions):
- •
EC2_HOST: The Elastic IP - •
EC2_USER:ubuntu - •
EC2_SSH_KEY: Contents of the.pemfile - •
APP_NAME: Project directory name
- •
- •
Test the pipeline: Have the user make a small change, push to main, and verify the deployment updates.
Phase 7: Verify & Handoff
Goal: Confirm everything works and give the user all the info they need.
Health Check
Verify the app is accessible from outside:
- •
curl -s -o /dev/null -w "%{http_code}" http://<ip-or-domain> - •Should return 200 (or 301/302 if redirecting)
Output Summary
Present the user with a clean summary:
✅ Deployment Complete! 🌐 URL: http://<elastic-ip> (or https://your-domain.com) 🖥️ SSH: ssh -i ~/.ssh/deploy-key.pem ubuntu@<elastic-ip> 📁 App location: /opt/apps/<project-name>/ 🔄 Auto-deploy: Push to `main` branch to redeploy Useful commands: docker compose logs -f # View logs docker compose restart # Restart services docker compose down && docker compose up -d --build # Full rebuild Cost reminder: - Free tier: $0/month for 12 months (t2.micro, 750 hrs/month) - After free tier: ~$10/month (t2.micro + EBS + Elastic IP) - Elastic IP costs ~$3.65/month if instance is STOPPED — release it or keep running
Updating an Existing Deployment
If the user has already deployed and wants to update:
- •If CI/CD is set up: "Just push to main!"
- •If no CI/CD: SSH in and run:
bash
cd /opt/apps/<project-name> git pull docker compose up -d --build
Error Handling
Common issues and how to handle them:
- •Port already in use:
docker compose downfirst, then bring back up - •Out of memory on t2.micro: Check if swap is active (
free -h), add swap if not, or suggest upgrading to t3.small - •Docker build fails: Check the Dockerfile, ensure all dependencies are listed
- •Can't SSH: Verify security group has port 22 open, key permissions are 400
- •HTTPS not working: Ensure DNS has propagated (
dig your-domain.com), check Caddy logs - •GitHub Actions failing: Check secrets are set correctly, SSH key format is right
- •Secrets leaked in git history: Help user rotate keys, suggest
git filter-branchor BFG Repo-Cleaner
What This Skill Does NOT Cover
Be upfront with the user if they need:
- •Multiple instances / load balancing → Suggest ECS or manual ALB setup
- •Serverless → Suggest Lambda + API Gateway
- •Static site only → Suggest S3 + CloudFront (much cheaper)
- •Managed database → Suggest RDS (but note the Postgres-in-Docker option works fine for small scale)
- •Large file storage → Suggest S3
- •Git workflow / project management → Out of scope, this skill is deployment only
These are out of scope intentionally. This skill is for simple, single-server deployments.