How I moved portfolio, krogerCart, and chat-bot off separate EC2 instances onto a single $5 Lightsail instance with Docker and Caddy and what broke along the way.
The problem: paying for servers, not traffic
I had three small web apps running on three separate EC2 instances:
| App | What it does | Rough monthly cost |
|---|---|---|
| portfolio | React SPA + Node API (Featherless chat) | ~$15 |
| krogerCart | Express API + auth (Cognito, Stripe, Kroger) | ~$15 |
| chat-bot | Express + DynamoDB chat UI | ~$15 |
Total: ~$45/month for sites that get almost no traffic.
EC2 pricing is mostly uptime, not requests. Three always-on boxes for hobby projects is like renting three parking spots when you only ever use one car at a time.
A fourth site, organic-bass, was already on S3 + CloudFront for pennies. That was the hint: static sites shouldn't sit on EC2, and dynamic apps don't each need their own box at this scale.
The goal: one box, three domains
Instead of three EC2 instances, I wanted:
shibleyrecords.com ─┐
www.grocerygoblin.com ─┼──► One Lightsail instance
chat.grocerygoblin.com ─┘ Docker + Caddy
Target cost: ~$5–8/month (Lightsail + Route 53 + a bit of bandwidth).
What stays separate: organic-bass on S3/CloudFront (~$1–3/mo). No reason to touch that.
Architecture
Each app stays in its own repo with its own Dockerfile. A small hosting/ repo ties them together:
hosting/
├── docker-compose.yml # builds all three apps + Caddy
├── Caddyfile # TLS + routing by hostname
└── env/
├── portfolio.env
├── krogercart.env
└── chatbot.env
Sibling directory layout on the server:
~/projects/
portfolio/
krogerCart/
chat-bot/
hosting/
docker-compose.yml
Three app services expose ports only inside Docker (8080, 8000, 4000). Caddy is the only service that publishes 80 and 443 to the internet:
services:
portfolio:
build: ../portfolio
expose: ["8080"]
env_file: ./env/portfolio.env
environment:
TRUST_PROXY: "1"
krogercart:
build: ../krogerCart
expose: ["8000"]
env_file: ./env/krogercart.env
environment:
TRUST_PROXY: "1"
chatbot:
build: ../chat-bot
expose: ["4000"]
env_file: ./env/chatbot.env
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
Caddyfile
Caddy terminates TLS (Let's Encrypt, automatic renewals) and reverse-proxies by hostname:
shibleyrecords.com, www.shibleyrecords.com, portfolio.shibleyrecords.com {
reverse_proxy portfolio:8080
}
www.grocerygoblin.com {
reverse_proxy krogercart:8000
}
chat.grocerygoblin.com {
reverse_proxy chatbot:4000
}
No nginx config files. No Certbot cron jobs. One process handles HTTPS for everything.
Why Lightsail instead of EC2?
For low-traffic personal projects:
| EC2 (×3) | Lightsail ($5) | |
|---|---|---|
| Billing | Per instance + EBS + data transfer | Flat ~$5/mo |
| Static IP | Elastic IP (free if attached) | Included |
| Firewall | Security groups | Simple rules in UI |
| Mental overhead | Higher | Lower |
I didn't need ECS, Fargate, or an ALB (~$16/mo alone). Consolidation beat "serverless migration" on effort for this workload.
DNS: one IP, two zones, several hostnames
This tripped me up more than Docker.
All A records point to the same Lightsail static IP (e.g. 184.33.2.158). Caddy routes by domain name, not by IP.
Two Route 53 hosted zones (one per domain):
shibleyrecords.com
| Name | Type | Value |
|---|---|---|
@ |
A | Lightsail IP |
www |
A | Lightsail IP |
portfolio |
A | Lightsail IP (optional) |
grocerygoblin.com
| Name | Type | Value |
|---|---|---|
www |
A | Lightsail IP |
chat |
A | Lightsail IP |
Critical: the four nameservers at your domain registrar must exactly match the NS record inside each hosted zone. I had three correct and one wrong (ns-375... vs ns-176...); DNS partially broke until I fixed it.
Don't delete hosted zones when you only mean to change A records. I learned that the hard way. Deleting the zone while the registrar still points at Route 53 nameservers leaves the domain in limbo (SERVFAIL / empty answers).
Remove old CloudFront CNAMEs. www.shibleyrecords.com still pointed at CloudFront, so Let's Encrypt's HTTP challenge hit CloudFront and returned 404. Replace with a plain A record to Lightsail.
Migration steps (the happy path)
- Provision Lightsail $5 plan, attach a static IP, open ports 80 and 443.
-
Install Docker + Compose on the instance (Amazon Linux didn't ship
docker composeout of the box for me). -
Clone repos and copy
.envfiles intohosting/env/. - Fix Route 53 A records → Lightsail IP; nameservers match the hosted zone.
-
Build and start:
docker compose up -d --build(see below, this takes a while). - Smoke test on the server (not your laptop):
curl -H "Host: www.grocerygoblin.com" http://127.0.0.1/api/health
- Restart Caddy so it picks up TLS once DNS propagates:
docker compose restart caddy
- Terminate the three old EC2 instances once HTTPS works.
Cognito, Stripe, and Kroger redirect URIs didn't need changes; domains stayed the same.
Gotchas I hit (so you don't have to)
1. Testing from the wrong machine
curl ifconfig.me on my Mac returned my home IP. I thought DNS was wrong. On the Lightsail SSH session, it correctly returned 184.33.2.158.
Always run server diagnostics over SSH on the instance.
2. You can't curl :8080 on the host
Apps only expose ports inside Docker, they aren't published on 127.0.0.1:8080 on the host. Test through Caddy on port 80 with a Host header, or exec into the container.
3. First docker compose build takes forever
A $5 Lightsail box has 512 MB–1 GB RAM. Running npm ci and frontend builds for three Node apps can take 20–45 minutes on first build. It looks frozen; it's often just swapping.
Mitigations:
- Add a 2 GB swap file before building.
- Build one service at a time:
docker compose build portfolio, then krogercart, then chatbot. - Later deploys:
docker compose up -d --build krogercartfor single-app updates.
4. Let's Encrypt fails if DNS is wrong
Caddy logs showed the real story:
-
NXDOMAIN hostname has no DNS record (
portfolio.shibleyrecords.com). - Connection timeout to old EC2 IP A record still pointed at a dead instance.
-
404 from CloudFront
wwwstill on a CDN CNAME, not Lightsail.
Fix DNS first; then restart Caddy.
5. docker compose vs docker-compose
On my Lightsail AMI, docker compose wasn't available until I installed the Compose plugin manually.
What we didn't do (on purpose)
- ECS + Fargate + ALB fixed costs exceed savings at this traffic level.
- Lambda rewrite high effort for modest gain vs consolidation.
- One EC2 per app "because isolation" operational overhead without benefit here.
Results
| Before | After |
|---|---|
| 3× EC2 ~$15 each | 1× Lightsail ~$5 |
| 3× TLS/nginx setups | 1× Caddy |
| ~$45/mo | ~$6–11/mo (incl. Route 53 + organic-bass S3) |
Same apps, same domains, same OAuth callbacks. Less money, less to babysit.
When this pattern fits
Good fit:
- Several small Node/Docker apps
- Low traffic, always-on is fine
- You control DNS and can point multiple domains at one IP
Bad fit:
- One app needs heavy CPU/RAM isolation
- Strict compliance requiring separate networks
- Traffic spikes that need autoscaling
Takeaways
- Separate EC2 per hobby app is usually waste you're paying for 720 hours × N instances.
- Caddy + Docker Compose multi-host routing is a simple consolidation pattern.
- DNS is half the migration one IP, many names; nameservers must match the hosted zone; don't leave CloudFront CNAMEs behind.
-
Test on the server with
Hostheaders before blaming DNS. - First build on a tiny VPS is slow plan for swap and patience.
The hosting/ docker-compose + Caddy setup is boring infrastructure in the best way: one $5 box, three domains, HTTPS that renews itself, and ~$35/month back in my pocket.
If you're doing something similar, start with DNS and a single curl through Caddy on localhost before you chase HTTPS smoke tests from your laptop. It'll save you an afternoon.`
Top comments (0)