A reference guide compiled from deploying two Node.js/Docker apps to AWS EC2, covering the real issues hit and how they were fixed. 1. Getting Connected Q: How do I SSH into my EC2 instance? chmod 400 your-key.pem ssh -i your-key.pem ubuntu@YOUR_ELASTIC_IP Type yes when asked about the fingerprint the first time. Q: chmod 400 doesn't seem to work / I get "bad permissions" / "Permission denied (publickey)" This happens when your .pem key sits on a Windows drive mounted into WSL (e.g. /mnt/c/Users/you/Downloads ). NTFS doesn't honor Linux permission bits properly. Fix: copy the key into WSL's native filesystem first. mkdir -p ~/.ssh cp "/mnt/c/Users/you/Downloads/your-key.pem" ~/.ssh/your-key.pem chmod 400 ~/.ssh/your-key.pem ssh -i ~/.ssh/your-key.pem ubuntu@YOUR_ELASTIC_IP Q: My key filename has spaces in it — how do I reference it? Wrap it in quotes: ssh -i "Terminal Key Pair.pem" ubuntu@YOUR_ELASTIC_IP Q: How do I know which actual instance/IP I'm connected to? TOKEN = TOKEN " http://169.254.169.254/latest/meta-data/instance-id curl -s -H "X-aws-ec2-metadata-token: TOKEN " http://169.254.169.254/latest/meta-data/public-ipv4 Compare this to what the AWS Console shows for your instance — it's easy to accidentally SSH into an old instance if an Elastic IP got reassigned. 2. Domain Name / HTTPS Without Buying a Domain Q: I don't want to buy a domain — can I still get real HTTPS? Yes — use sslip.io . Any hostname like YOUR_IP.sslip.io automatically resolves to that IP with zero signup. Let's Encrypt (via Certbot) will issue a real, trusted certificate for it just like a paid domain. Q: Why can't I just use the raw IP with HTTP? Clerk (auth) and Razorpay (payments) both require HTTPS with a real hostname in production/live mode. Plain http://ip will not work with either. Q: I later bought a real domain — how do I switch over? In your registrar's DNS panel, add an A record: Host @ → your Elastic IP (add one for www too if wanted). Wait for propagation ( nslookup yourdomain.com should return your IP). Update your Nginx server_name and re-run Certbot with the new domain. Update Clerk/Razorpay webhook URLs and CORS settings to the new domain. 3. Elastic IP Q: Do I need an Elastic IP? Yes — without one, your instance's public IP changes on every stop/start, breaking your domain/cert setup. Allocate one (EC2 → Elastic IPs → Allocate) and associate it with your instance (free while attached to a running instance). 4. Disk Space Q: docker compose up --build fails with "no space left on device" Default EC2 root volumes are often only ~8GB (or less after later resizes are lost) — too small for Docker image layers. Check with: df -h docker system df Quick relief: docker system prune -a -f (removes unused images/cache, doesn't touch running containers' data). Q: How do I permanently fix a too-small disk? AWS Console → EC2 → Volumes → select the volume → Actions → Modify Volume → increase size (e.g. 25 GiB) → Modify. No downtime. On the server, extend the partition: lsblk # find your partition name, e.g. nvme0n1p1 sudo growpart /dev/nvme0n1 1 sudo resize2fs /dev/nvme0n1p1 df -h # confirm new size 5. Memory / Swap Q: My Docker build crashes with "JavaScript heap out of memory" or gets silently killed Free-tier EC2 instances (t2/t3.micro) typically have ~1GB RAM — not enough for tsc / vite build under load. Fix: add swap space. sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab free -h Q: fallocate fails with "No space left on device" while adding swap Your disk is full — fix disk space first (Section 4), then retry the swap commands. Q: A container keeps restarting with a clean "Exited (0)" or crash-looping Check for OOM kills: docker inspect CONTAINER_NAME --format = '{{.State.OOMKilled}} | RestartCount: {{.RestartCount}}' sudo dmesg | grep -i "killed process" If OOMKilled: true , add swap. If not, check disk space and container logs for the real error. Q: Node's build step still runs out of memory even with swap Explicitly raise Node's own heap limit in the Dockerfile: RUN NODE_OPTIONS = --max-old-space-size = 1536 npm run build --workspace = client 6. Nginx + Certbot (Reverse Proxy + Free SSL) Q: Basic Nginx + Certbot setup for a Docker app (server on :5000/5001, client on :5173)? sudo apt install -y nginx certbot python3-certbot-nginx sudo systemctl stop nginx sudo certbot certonly --standalone -d YOUR_IP.sslip.io Then create /etc/nginx/sites-available/yourapp : server { listen 80 ; server_name YOUR_IP.sslip.io ; return 301 https:// hostrequest_uri ; } server { listen 443 ssl ; server_name YOUR_IP.sslip.io ; ssl_certificate /etc/letsencrypt/live/YOUR_IP.sslip.io/fullchain.pem ; ssl_certificate_key /etc/letsencrypt/live/YOUR_IP.sslip.io/privkey.pem ; location / { proxy_pass http://localhost:5173 ; proxy_http_version 1.1 ; proxy_set_header Upgrade http_upgrade ; proxy_set_header Connection 'upgrade' ; proxy_set_header Host host ; } location /api { proxy_pass http://localhost:5001 ; proxy_http_version 1.1 ; proxy_set_header Upgrade http_upgrade ; proxy_set_header Connection 'upgrade' ; proxy_set_header Host host ; } location /socket.io { proxy_pass http://localhost:5001 ; proxy_http_version 1.1 ; proxy_set_header Upgrade http_upgrade ; proxy_set_header Connection 'upgrade' ; proxy_set_header Host host ; } } sudo ln -sf /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/ sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl start nginx Q: Why get the cert with certonly --standalone before starting Nginx, instead of certbot --nginx ? Because your Nginx config already references the cert files (chicken-and-egg problem) — nginx -t would fail before the cert exists. Getting it standalone first (with Nginx stopped, freeing port 80) avoids this. Q: How do I swap a self-signed cert for a real Let's Encrypt one later? sudo systemctl stop nginx sudo certbot certonly --standalone -d YOUR_IP.sslip.io sudo sed -i \ -e 's|ssl_certificate .*|ssl_certificate /etc/letsencrypt/live/YOUR_IP.sslip.io/fullchain.pem;|' \ -e 's|ssl_certificate_key .*|ssl_certificate_key /etc/letsencrypt/live/YOUR_IP.sslip.io/privkey.pem;|' \ -e 's/server_name _;/server_name YOUR_IP.sslip.io;/' \ /etc/nginx/sites-available/default sudo nginx -t && sudo systemctl start nginx Q: Can I use Caddy instead of Nginx+Certbot? Yes — Caddy auto-issues and renews HTTPS certs with zero manual Certbot commands. A minimal Caddyfile: YOUR_IP.sslip.io { handle /api/* { reverse_proxy localhost : 5001 } handle { reverse_proxy localhost : 5173 } } Only run one of Nginx or Caddy at a time — both fight over ports 80/443. 7. Docker Compose Gotchas Q: docker compose vs docker-compose (hyphen)? Newer Docker installs ( docker.io package) ship the Compose plugin , invoked as docker compose (space). The old standalone docker-compose binary may not exist. If docker-compose isn't found, use docker compose instead, or sudo apt install docker-compose-plugin . Q: permission denied running docker without sudo sudo usermod -aG docker ubuntu newgrp docker # or log out and back in Q: .env file not found even though I created it Check it's actually named .env (leading dot), not env : ls -la | grep env mv env .env # if needed Q: My frontend deployed but shows a blank page / can't reach the API If your frontend calls a Docker-internal hostname (e.g. http://server:5001 ) as its API base URL, that only works inside Docker's network — a real browser on the user's machine has no idea what server means. Point your frontend's API base URL at your real public HTTPS URL instead (e.g. https://YOUR_IP.sslip.io/api ). 8. WebSocket / Socket.IO Issues Q: WebSocket fails with ERR_CERT_COMMON_NAME_INVALID Your frontend is connecting to a different hostname than the one your SSL cert was issued for (e.g. connecting to the raw IP while the cert covers IP.sslip.io ). Make sure the frontend's backend URL env var uses the exact same hostname as your cert and Nginx server_name . Q: WebSocket connects but errors with "Invalid namespace" Socket.IO treats the path portion of the connection URL as a namespace. If your env var is https://yourhost/api and your code does io(BACKEND_URL) , Socket.IO tries to connect to an /api namespace that doesn't exist on your server. Fix: use a backend URL without any path suffix for the socket connection (just the origin), and append /api separately only for REST calls. 9. MongoDB Q: Local Docker Mongo container keeps crash-looping / segfaulting (exit code 139) On tiny EC2 instances (900MB–1GB RAM), running Mongo locally alongside your app is often unstable. Simpler and more reliable: use a free MongoDB Atlas cluster instead of a local container. Atlas → Network Access → whitelist your EC2's Elastic IP. Set MONGO_URI / MONGODB_URI in .env to your Atlas connection string. Remove the local mongodb: service from docker-compose.yml (and any depends_on: mongodb references). Q: My Atlas connection string fails to parse If your password contains an @ symbol, it must be URL-encoded as %40 , or the driver misreads the string (it'll look like there are two @ symbols). mongodb+srv://user:my%40pass@cluster0.xxxxx.mongodb.net/dbname?retryWrites=true&w=majority 10. CORS Q: API/socket requests get rejected from my live site Your server's CORS_ORIGIN (or equivalent) env var must exactly match your production URL — scheme, host, and no trailing slash mismatch. E.g.: CORS_ORIGIN = https://YOUR_IP.sslip.io Not http://localhost:5173 (leftover dev default) and not missing the .sslip.io suffix if that's part of your real hostname. 11. Vite-Specific Q: Vite dev server blocks my domain: "Blocked request. This host is not allowed." Vite 5+ blocks unrecognized Host headers by default in dev mode. Add your hostname to vite.config.js : server : { host : true , allowedHosts : [ ' YOUR_IP.sslip.io ' , ' YOUR_IP ' ], } 12. Dead / Legacy Code Breaking Builds Q: TypeScript build fails on files that seem unrelated to my app tsc type-checks everything under your configured include path (usually all of src/ ), even orphaned files nothing imports. If you migrated auth systems, frameworks, etc., leftover dead code can still break the build. Confirm a file/folder is truly unused before deleting: grep -rln "the-thing-you-suspect" src --include = "*.ts" --include = "*.tsx" If nothing outside the suspect file/folder references it, it's safe to delete. 13. Security / Credential Hygiene Q: I accidentally pasted real secrets (DB password, API keys) somewhere they shouldn't be Treat them as compromised immediately: MongoDB Atlas : Database Access → edit user → generate new password → update your connection string everywhere. API keys (Clerk, Razorpay, Gemini, etc.) : regenerate from the provider's dashboard, update .env . JWT secrets : cheap to rotate, no external account tied to them — just generate new ones: openssl rand -base64 48 Never hardcode secrets directly in docker-compose.yml — always reference them via env_file: .env or {VAR} substitution. 14. Quick Diagnostic Command Reference # Disk df -h docker system df docker system prune -a -f # Memory free -h docker inspect CONTAINER --format = '{{.State.OOMKilled}}' # Containers docker compose ps -a docker logs CONTAINER --tail 50 docker compose up -d --build # Nginx sudo nginx -t sudo systemctl status nginx --no-pager sudo cat /etc/nginx/sites-enabled/ * # Networking / identity curl -I https://yourhost TOKEN = TOKEN " http://169.254.169.254/latest/meta-data/instance-id

AWS EC2 Deployment — Q&A Reference
harsh

