Build journal
ARCHITECTUREArchitecture 5 min read

AWS Deployment

Published Jul 6, 2026

One Ubuntu EC2 running the exact same Docker Compose stack as dev, behind host Nginx and Let's Encrypt.

Most "deploy to AWS" tutorials reach for a managed control plane before the product has a single user: ECS task definitions, an RDS instance, an ALB, a Terraform mono-repo, and a CI job that takes twenty minutes to plan. Praxis924 runs the entire platform — FastAPI, PostgreSQL + pgvector, Redis, DBOS workflows, and a Next.js frontend — on one Ubuntu EC2 instance running the exact same Docker Compose stack we develop against locally. This post is the whole design, including the one-character Nginx mistake that took the site down.

The problem

The naive cloud-native path optimizes for a scale you don't have yet, and it does so by making dev and prod different. The moment your local docker compose up doesn't match production, every bug becomes "does it reproduce on the server?" You debug the deployment instead of the code. Managed Postgres, a load balancer per service, and a separate container registry are all real infrastructure that has to be provisioned, versioned, and paid for — and none of it earns its keep for a single-box workload. Worse, each managed piece is a place where prod silently drifts from the compose file in your repo.

The design goal is the opposite: prod is dev, on a bigger machine, with TLS in front of it.

The design

One EC2 instance. The same docker-compose.yml that runs db / redis / backend / frontend locally runs on the box. TLS and routing are the only things that differ, and they live on the host, not in a container:

  • Nginx on the host terminates HTTPS and reverse-proxies to the containers on 127.0.0.1.
  • Let's Encrypt via certbot owns the certificate and the TLS server block.
  • The database lives on a separate EBS volume so the app disk and the data disk have independent lifecycles — you can rebuild the instance without touching learner progress or generated lessons.
  • No Terraform. Infra (instance, Elastic IP, security group, data EBS) is provisioned by hand in the AWS console.

Deployment is a GitHub Actions job (deploy.yml) that runs only after CI is green on main. It SSHes to the box and does the boring, durable thing:

bash
1# deploy.yml, on the EC2 host — CI already passed on main
2ssh $DEPLOY_USER@$DEPLOY_HOST <<'EOF'
3  cd /srv/praxis924
4  ./scripts/db-backup.sh                 # snapshot before touching anything
5  git reset --hard origin/main           # server dir is a real clone of the repo
6  docker compose up -d --build            # same stack as dev, rebuilt
7  python app/scripts/preflight_migration_check.py   # abort if DB stamp is wrong
8  docker compose exec -T backend alembic upgrade head
9  curl -fsS http://127.0.0.1:8000/health  # health-gate the deploy
10EOF
Powered by AI

Because the server directory is a genuine git clone whose origin is GitHub, git reset --hard is safe for tracked files — and .env plus the Docker data volumes are git-ignored, so they survive every deploy.

How the Nginx layer works

The host Nginx block is small, and one line in it is load-bearing. The backend serves its API under /api/v1/*, so Nginx has to forward /api preserving the path:

nginx
1location /api {
2    # NO trailing slash — this is the whole trap
3    proxy_pass http://127.0.0.1:8000;
4    proxy_set_header Host $host;
5    proxy_set_header X-Forwarded-Proto $scheme;
6    proxy_read_timeout 300s;   # SSE streams (Lucy, generation) stay open
7}
8
9location / {
10    proxy_pass http://127.0.0.1:3000;   # Next.js frontend
11}
Powered by AI

The proxy_read_timeout matters because Lucy's chat and lesson generation stream over SSE — a short timeout cuts responses mid-token.

Warning: proxy_pass http://127.0.0.1:8000; must have no trailing slash. Add one — .../; — and Nginx rewrites /api/v1/lessons to /v1/lessons, stripping the /api prefix, and every API call 404s. This is exactly what caused the /learn "Not Found" outage. The config lives on the host, so a server rebuild can silently reintroduce it; the canonical file is committed in the repo — re-copy it.

The gotchas

TrapWhat happensFix
Trailing slash on proxy_pass/api/v1/*/v1/*, all API calls 404Committed Nginx config, no trailing slash
docker compose down -v on the server-v drops volumes = wipes the databaseNever run it on prod; it's dev-only
Hand-editing tracked files on the boxgit reset --hard blows them away next deployChange code in the repo, let CD ship it
Detaching/reformatting the data EBSNo IaC safety net — unrecoverableDouble-check every console action against that volume
Unstamped/mismatched Alembic stateupgrade head crashes mid-deployPreflight check aborts before migrating

Note: the data EBS volume is manually load-bearing. With no Terraform, nothing stops an accidental detach or reformat, and there's no terraform plan diff to review first. Snapshot it before any console action that touches it.

Build it yourself

The minimal version is genuinely small:

  1. Launch an Ubuntu EC2 instance, attach a second EBS volume, mount it, and point Docker's data dir (or your DB volume) at it.
  2. Install Docker + Compose, git clone your repo into /srv/<app>, drop a real .env beside it.
  3. docker compose up -d --build.
  4. apt install nginx certbot, write the two location blocks above, run certbot --nginx for TLS.
  5. Add a GitHub Actions job that SSHes in, backs up, git reset --hard origin/main, rebuilds, migrates, and health-checks — gated behind your CI workflow.

That's a production deployment. It looks like your laptop because it is your laptop's stack.

What changes at scale

The single box is a deliberate stage, not a ceiling. When traffic or team size demands it: move Postgres to RDS (keeping the same connection string shape), pull Redis out to ElastiCache, put an ALB in front of multiple backend instances, and codify the console-clicked infra as Terraform so the data volume can't be destroyed by hand. Each of those is a swap of one layer — the Compose file, the app code, and the dev workflow stay put. You pay for that complexity when it buys you something, not before.

Published in build journal