# AWS Deployment (EC2 + Elastic IP + Cloudflare + HTTPS) This deploys Seedcorn to a **single EC2 instance** — Postgres, gunicorn, and nginx all in Docker on the box, behind an **elastic IP**, with DNS and TLS on **Cloudflare**. Cloudflare's proxy terminates browser-facing HTTPS at its edge; nginx presents a **Cloudflare Origin Certificate** to secure the Cloudflare→origin leg. Infrastructure is managed with **Terraform**. It's sized for a small team now and has a clear path to ~3000 users (see [Scaling](#scaling-to-3000-users)). ``` Cloudflare proxy (timeline.example.com → Elastic IP) — edge TLS, Full (strict) │ HTTPS ▼ EC2 instance ── nginx (Origin Cert, SPA + reverse proxy) ── gunicorn (Django) ── Postgres (containers, EBS volume) ``` ## Prerequisites - An **AWS account** and the AWS CLI configured (`aws configure`) — Terraform uses these creds. - **Terraform ≥ 1.5** installed locally. - An **SSH key pair** locally (e.g. `~/.ssh/id_ed25519[.pub]`). - The **Cloudflare** domain (`timeline.example.com`) you control. ## 1. Provision the infrastructure (Terraform) ```bash cd terraform cp terraform.tfvars.example terraform.tfvars # Edit terraform.tfvars: set allowed_ssh_cidr to YOUR_IP/32, confirm region/instance_type/key path. terraform init terraform apply ``` Terraform creates a VPC + public subnet + internet gateway, a security group (SSH from your IP, 80/443 open), an **Amazon Linux 2023** instance (Docker + Compose + git installed via cloud-init), a **gp3 EBS root volume** (encrypted), and an **elastic IP**. Outputs include the IP and next steps: ```bash terraform output public_ip # e.g. 52.x.x.x terraform output next_steps ``` ## 2. DNS (Cloudflare) Create an **A record**: `timeline.example.com → `. > While testing, you can leave the record **DNS only** (grey cloud) to hit the origin directly. > Once it's working, switch the proxy **on** (orange cloud) and set Cloudflare's SSL/TLS mode to > **Full (strict)** so the edge validates the origin certificate. > > Note: Cloudflare's SSL/TLS encryption mode is **zone-wide**, so switching to **Full (strict)** > affects *every* proxied hostname in the zone — make sure all of them present a valid origin > cert first. ## 3. Deploy the app ```bash ssh ec2-user@ cd /opt/timeline git clone https://github.com/vakahnke/seedcorn.git . cp .env.example .env ``` Edit **`.env`** for production: ```bash DJANGO_DEBUG=0 DJANGO_SECRET_KEY= POSTGRES_PASSWORD= DATABASE_URL=postgres://timeline:@db:5432/timeline DJANGO_ALLOWED_HOSTS=timeline.example.com DJANGO_CSRF_TRUSTED_ORIGINS=https://timeline.example.com DJANGO_CORS_ALLOWED_ORIGINS= RUN_COLLECTSTATIC=1 DOMAIN=timeline.example.com WEB_CONCURRENCY=3 # Account approval + email (new sign-ups stay inactive until you approve them) REQUIRE_ACCOUNT_APPROVAL=1 SITE_URL=https://timeline.example.com ACCOUNT_NOTIFY_EMAIL=you@gmail.com EMAIL_HOST_USER=you@gmail.com EMAIL_HOST_PASSWORD=your-16-char-app-password # EMAIL_HOST/PORT/USE_TLS default to smtp.gmail.com/587/on; DEFAULT_FROM_EMAIL -> EMAIL_HOST_USER ``` > **Gmail SMTP:** use an **App Password** (Google Account → Security → 2-Step Verification → > App passwords), not your normal password. Or use a transactional provider (SES, Postmark, > Resend) — just point the `EMAIL_HOST_*` vars at its SMTP endpoint. Install the origin certificate, then bring the stack up: ```bash # In the Cloudflare dashboard: SSL/TLS → Origin Server → Create Certificate # (defaults: RSA, hostnames *.example.com + example.com, 15-year validity). # Copy the Origin Certificate and Private Key onto the box: mkdir -p nginx/certs # paste the Origin Certificate into nginx/certs/origin.pem # paste the Private Key into nginx/certs/origin.key docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml exec backend python manage.py createsuperuser ``` `migrate` + `collectstatic` run automatically on backend start. nginx mounts `nginx/certs/origin.pem` + `origin.key` read-only (both are gitignored). The app is now live at **https://timeline.example.com** once the Cloudflare proxy is on (orange) with SSL/TLS mode **Full (strict)**. ## 4. Backups ```bash # manual ./deploy/backup-db.sh # daily cron at 03:00 ( crontab -l 2>/dev/null; echo "0 3 * * * cd /opt/timeline && ./deploy/backup-db.sh >> backups/backup.log 2>&1" ) | crontab - ``` Dumps go to `backups/` (gzipped, rotated `BACKUP_KEEP_DAYS`). Set `S3_BACKUP_BUCKET` in `.env` (and attach an S3 IAM role to the instance) to copy them off-box. Restore: ```bash gunzip -c backups/.sql.gz | docker compose -f docker-compose.prod.yml exec -T db psql -U timeline timeline ``` ## Updating ```bash cd /opt/timeline && git pull docker compose -f docker-compose.prod.yml up -d --build ``` There's no cert renewal to worry about — the Cloudflare Origin Certificate is valid for ~15 years. --- ## Sizing The whole stack runs on one box (Postgres included). Approximate steady-state memory: | Component | RAM | |---|---| | Amazon Linux 2023 | ~0.3 GB | | Docker daemon | ~0.1 GB | | PostgreSQL 16 | ~0.2 GB | | gunicorn (3 workers × ~120 MB) | ~0.4 GB | | nginx | ~0.05 GB | | **Runtime total** | **~1.0–1.3 GB** | | Building the frontend image on-box (vite/node, transient) | **+1–1.5 GB peak** | **Recommendation (now — a team of 3, continued development):** | Option | Instance | vCPU / RAM | Notes | ~On-demand* | |---|---|---|---|---| | **Recommended** | **t4g.medium** | 2 / 4 GiB | Comfortable runtime + on-box image builds + DB growth + headroom. **Default in Terraform.** | ~$24/mo | | Budget | t4g.small | 2 / 2 GiB | Runs fine at runtime; add a **2 GB swapfile** to build images without OOM. | ~$12/mo | | x86 | t3.medium / t3.small | same | Set `ami_architecture = "x86_64"`. Slightly pricier than Graviton. | ~$30 / $15 | \* us-east-1, plus ~$2.4/mo for the 30 GiB gp3 volume. The elastic IP is free while attached. **t4g (Graviton/ARM)** is the best value — every image here is multi-arch, so it just works. Add swap on a small box: ```bash sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 && sudo chmod 600 /swapfile sudo mkswap /swapfile && sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab ``` Tune `WEB_CONCURRENCY` (gunicorn workers) to the box: a rule of thumb is `2 × vCPU + 1`, but it's memory-bound (~120 MB/worker), so `3` is right for a 2-vCPU/4-GiB box. ## Scaling to ~3000 users 3000 *registered* users is light for this CRUD app — the concern is concurrency and resilience, not raw compute. When you outgrow the single box, evolve it in this order (the Terraform VPC is the base to build on): 1. **Postgres → Amazon RDS** (`db.t4g.medium`, then `db.m6g.large`): managed backups, point-in-time restore, easy vertical scaling. Point `DATABASE_URL` at the RDS endpoint and drop the `db` container. This alone removes the DB from the app box's memory budget. 2. **Build images in CI → push to ECR**; instances *pull* (no on-box builds → smaller app boxes). 3. **Scale the web tier** — either one larger instance (`m6g.large`/`xlarge`, 8/16 GiB, 8–16 gunicorn workers) **or** an **Application Load Balancer + Auto Scaling Group** of 2+ `t4g.large` app instances. Terminate TLS at the **ALB with an ACM cert** (then the box-level origin-cert/nginx-TLS is no longer needed — nginx just serves the SPA + proxies). 4. **CloudFront** in front of the SPA/assets for CDN caching (optional, helps global latency). 5. Consider **PgBouncer** and tuned `shared_buffers` / `max_connections` on the DB. A reasonable 3000-user starting topology: **ALB + ASG (2× t4g.large) + RDS db.t4g.medium**, images from ECR, TLS via ACM. Rough cost ~$200–400/mo depending on instance count and RDS size. ## Operational notes - **Security group:** SSH is restricted to `allowed_ssh_cidr` (set it!); 80/443 are public. IMDSv2 is enforced and the EBS root volume is encrypted. - **Logs:** `docker compose -f docker-compose.prod.yml logs -f backend` (gunicorn logs to stdout). - **Admin:** `https://timeline.example.com/admin/` (after `createsuperuser`). - **Health check:** `GET /api/health/` returns `200` (`{"status":"ok"}`) — use it for monitoring or an ALB target-group health check later. - **Approving accounts:** new sign-ups are inactive; you're emailed when one registers. Approve in `…/admin/auth/user/` (tick **Active**, or the **"Approve & notify"** action) and the person is emailed automatically. Set `REQUIRE_ACCOUNT_APPROVAL=0` to run an open instance. - **Secrets:** `.env` lives only on the box (gitignored). For a team, consider AWS SSM Parameter Store / Secrets Manager.